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..c6106236 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,689 @@ + +# Changelog +All notable changes to Tailbone will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## v0.22.8 (2025-05-20) + +### Fix + +- add startup hack for tempmon DB model + +## v0.22.7 (2025-02-19) + +### Fix + +- stop using old config for logo image url on login page +- fix warning msg for deprecated Grid param + +## v0.22.6 (2025-02-01) + +### Fix + +- register vue3 form component for products -> make batch + +## v0.22.5 (2024-12-16) + +### Fix + +- whoops this is latest rattail +- require newer rattail lib +- require newer wuttaweb +- let caller request safe HTML literal for rendered grid table + +## v0.22.4 (2024-11-22) + +### Fix + +- avoid error in product search for duplicated key +- use vmodel for confirm password widget input + +## v0.22.3 (2024-11-19) + +### Fix + +- avoid error for trainwreck query when not a customer + +## v0.22.2 (2024-11-18) + +### Fix + +- use local/custom enum for continuum operations +- add basic master view for Product Costs +- show continuum operation type when viewing version history +- always define `app` attr for ViewSupplement +- avoid deprecated import + +## v0.22.1 (2024-11-02) + +### Fix + +- fix submit button for running problem report +- avoid deprecated grid method + +## v0.22.0 (2024-10-22) + +### Feat + +- add support for new ordering batch from parsed file + +### Fix + +- avoid deprecated method to suggest username + +## v0.21.11 (2024-10-03) + +### Fix + +- custom method for adding grid action +- become/stop root should redirect to previous url + +## v0.21.10 (2024-09-15) + +### Fix + +- update project repo links, kallithea -> forgejo +- use better icon for submit button on login page +- wrap notes text for batch view +- expose datasync consumer batch size via configure page + +## v0.21.9 (2024-08-28) + +### Fix + +- render custom attrs in form component tag + +## v0.21.8 (2024-08-28) + +### Fix + +- ignore session kwarg for `MasterView.make_row_grid()` + +## v0.21.7 (2024-08-28) + +### Fix + +- avoid error when form value cannot be obtained + +## v0.21.6 (2024-08-28) + +### Fix + +- avoid error when grid value cannot be obtained + +## v0.21.5 (2024-08-28) + +### Fix + +- set empty string for "-new-" file configure option + +## v0.21.4 (2024-08-26) + +### Fix + +- handle differing email profile keys for appinfo/configure + +## v0.21.3 (2024-08-26) + +### Fix + +- show non-standard config values for app info configure email + +## v0.21.2 (2024-08-26) + +### Fix + +- refactor waterpark base template to use wutta feedback component +- fix input/output file upload feature for configure pages, per oruga +- tweak how grid data translates to Vue template context +- merge filters into main grid template +- add basic wutta view for users +- some fixes for wutta people view +- various fixes for waterpark theme +- avoid deprecated `component` form kwarg + +## v0.21.1 (2024-08-22) + +### Fix + +- misc. bugfixes per recent changes + +## v0.21.0 (2024-08-22) + +### Feat + +- move "most" filtering logic for grid class to wuttaweb +- inherit from wuttaweb templates for home, login pages +- inherit from wuttaweb for AppInfoView, appinfo/configure template +- add "has output file templates" config option for master view + +### Fix + +- change grid reset-view param name to match wuttaweb +- move "searchable columns" grid feature to wuttaweb +- use wuttaweb to get/render csrf token +- inherit from wuttaweb for appinfo/index template +- prefer wuttaweb config for "home redirect to login" feature +- fix master/index template rendering for waterpark theme +- fix spacing for navbar logo/title in waterpark theme + +## v0.20.1 (2024-08-20) + +### Fix + +- fix default filter verbs logic for workorder status + +## v0.20.0 (2024-08-20) + +### Feat + +- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy +- refactor templates to simplify base/page/form structure + +### Fix + +- avoid deprecated reference to app db engine + +## v0.19.3 (2024-08-19) + +### Fix + +- add pager stats to all grid vue data (fixes view history) + +## v0.19.2 (2024-08-19) + +### Fix + +- sort on frontend for appinfo package listing grid +- prefer attr over key lookup when getting model values +- replace all occurrences of `component_studly` => `vue_component` + +## v0.19.1 (2024-08-19) + +### Fix + +- fix broken user auth for web API app + +## v0.19.0 (2024-08-18) + +### Feat + +- move multi-column grid sorting logic to wuttaweb +- move single-column grid sorting logic to wuttaweb + +### Fix + +- fix misc. errors in grid template per wuttaweb +- fix broken permission directives in web api startup + +## v0.18.0 (2024-08-16) + +### Feat + +- move "basic" grid pagination logic to wuttaweb +- inherit from wutta base class for Grid +- inherit most logic from wuttaweb, for GridAction + +### Fix + +- avoid route error in user view, when using wutta people view +- fix some more wutta compat for base template + +## v0.17.0 (2024-08-15) + +### Feat + +- use wuttaweb for `get_liburl()` logic + +## v0.16.1 (2024-08-15) + +### Fix + +- improve wutta People view a bit +- update references to `get_class_hierarchy()` +- tweak template for `people/view_profile` per wutta compat + +## v0.16.0 (2024-08-15) + +### Feat + +- add first wutta-based master, for PersonView +- refactor forms/grids/views/templates per wuttaweb compat + +## v0.15.6 (2024-08-13) + +### Fix + +- avoid `before_render` subscriber hook for web API +- simplify verbiage for batch execution panel + +## v0.15.5 (2024-08-09) + +### Fix + +- assign convenience attrs for all views (config, app, enum, model) + +## v0.15.4 (2024-08-09) + +### Fix + +- avoid bug when checking current theme + +## v0.15.3 (2024-08-08) + +### Fix + +- fix timepicker `parseTime()` when value is null + +## v0.15.2 (2024-08-06) + +### Fix + +- use auth handler, avoid legacy calls for role/perm checks + +## v0.15.1 (2024-08-05) + +### Fix + +- move magic `b` template context var to wuttaweb + +## v0.15.0 (2024-08-05) + +### Feat + +- move more subscriber logic to wuttaweb + +### Fix + +- use wuttaweb logic for `util.get_form_data()` + +## v0.14.5 (2024-08-03) + +### Fix + +- use auth handler instead of deprecated auth functions +- avoid duplicate `partial` param when grid reloads data + +## v0.14.4 (2024-07-18) + +### Fix + +- fix more settings persistence bug(s) for datasync/configure +- fix modals for luigi tasks page, per oruga + +## v0.14.3 (2024-07-17) + +### Fix + +- fix auto-collapse title for viewing trainwreck txn +- allow auto-collapse of header when viewing trainwreck txn + +## v0.14.2 (2024-07-15) + +### Fix + +- add null menu handler, for use with API apps + +## v0.14.1 (2024-07-14) + +### Fix + +- update usage of auth handler, per rattail changes +- fix model reference in menu handler +- fix bug when making "integration" menus + +## v0.14.0 (2024-07-14) + +### Feat + +- move core menu logic to wuttaweb + +## v0.13.2 (2024-07-13) + +### Fix + +- fix logic bug for datasync/config settings save + +## v0.13.1 (2024-07-13) + +### Fix + +- fix settings persistence bug(s) for datasync/configure page + +## v0.13.0 (2024-07-12) + +### Feat + +- begin integrating WuttaWeb as upstream dependency + +### Fix + +- cast enum as list to satisfy deform widget + +## v0.12.1 (2024-07-11) + +### Fix + +- refactor `config.get_model()` => `app.model` + +## v0.12.0 (2024-07-09) + +### Feat + +- drop python 3.6 support, use pyproject.toml (again) + +## v0.11.10 (2024-07-05) + +### Fix + +- make the Members tab optional, for profile view + +## v0.11.9 (2024-07-05) + +### Fix + +- do not show flash message when changing app theme + +- improve collapse panels for butterball theme + +- expand input for butterball theme + +- add xref button to customer profile, for trainwreck txn view + +- add optional Transactions tab for profile view + +## v0.11.8 (2024-07-04) + +### Fix + +- fix grid action icons for datasync/configure, per oruga + +- allow view supplements to add extra links for profile employee tab + +- leverage import handler method to determine command/subcommand + +- add tool to make user account from profile view + +## v0.11.7 (2024-07-04) + +### Fix + +- add stacklevel to deprecation warnings + +- require zope.sqlalchemy >= 1.5 + +- include edit profile email/phone dialogs only if user has perms + +- allow view supplements to add to profile member context + +- cast enum as list to satisfy deform widget + +- expand POD image URL setting input + +## v0.11.6 (2024-07-01) + +### Fix + +- set explicit referrer when changing dbkey + +- remove references, dependency for `six` package + +## v0.11.5 (2024-06-30) + +### Fix + +- allow comma in numeric filter input + +- add custom url prefix if needed, for fanstatic + +- use vue 3.4.31 and oruga 0.8.12 by default + +## v0.11.4 (2024-06-30) + +### Fix + +- start/stop being root should submit POST instead of GET + +- require vendor when making new ordering batch via api + +- don't escape each address for email attempts grid + +## v0.11.3 (2024-06-28) + +### Fix + +- add link to "resolved by" user for pending products + +- handle error when merging 2 records fails + +## v0.11.2 (2024-06-18) + +### Fix + +- hide certain custorder settings if not applicable + +- use different logic for buefy/oruga for product lookup keydown + +- product records should be touchable + +- show flash error message if resolve pending product fails + +## v0.11.1 (2024-06-14) + +### Fix + +- revert back to setup.py + setup.cfg + +## v0.11.0 (2024-06-10) + +### Feat + +- switch from setup.cfg to pyproject.toml + hatchling + +## v0.10.16 (2024-06-10) + +### Feat + +- standardize how app, package versions are determined + +### Fix + +- avoid deprecated config methods for app/node title + +## v0.10.15 (2024-06-07) + +### Fix + +- do *not* Use `pkg_resources` to determine package versions + +## v0.10.14 (2024-06-06) + +### Fix + +- use `pkg_resources` to determine package versions + +## v0.10.13 (2024-06-06) + +### Feat + +- remove old/unused scaffold for use with `pcreate` + +- add 'fanstatic' support for sake of libcache assets + +## v0.10.12 (2024-06-04) + +### Feat + +- require pyramid 2.x; remove 1.x-style auth policies + +- remove version cap for deform + +- set explicit referrer when changing app theme + +- add `<b-tooltip>` component shim + +- include extra styles from `base_meta` template for butterball + +- include butterball theme by default for new apps + +### Fix + +- fix product lookup component, per butterball + +## v0.10.11 (2024-06-03) + +### Feat + +- fix vue3 refresh bugs for various views + +- fix grid bug for tempmon appliance view, per oruga + +- fix ordering worksheet generator, per butterball + +- fix inventory worksheet generator, per butterball + +## v0.10.10 (2024-06-03) + +### Feat + +- more butterball fixes for "view profile" template + +### Fix + +- fix focus for `<b-select>` shim component + +## v0.10.9 (2024-06-03) + +### Feat + +- let master view control context menu items for page + +- fix the "new custorder" page for butterball + +### Fix + +- fix panel style for PO vs. Invoice breakdown in receiving batch + +## v0.10.8 (2024-06-02) + +### Feat + +- add styling for checked grid rows, per oruga/butterball + +- fix product view template for oruga/butterball + +- allow per-user custom styles for butterball + +- use oruga 0.8.9 by default + +## v0.10.7 (2024-06-01) + +### Feat + +- add setting to allow decimal quantities for receiving + +- log error if registry has no rattail config + +- add column filters for import/export main grid + +- escape all unsafe html for grid data + +- add speedbumps for delete, set preferred email/phone in profile view + +- fix file upload widget for oruga + +### Fix + +- fix overflow when instance header title is too long (butterball) + +## v0.10.6 (2024-05-29) + +### Feat + +- add way to flag organic products within lookup dialog + +- expose db picker for butterball theme + +- expose quickie lookup for butterball theme + +- fix basic problems with people profile view, per butterball + +## v0.10.5 (2024-05-29) + +### Feat + +- add `<tailbone-timepicker>` component for oruga + +## v0.10.4 (2024-05-12) + +### Fix + +- fix styles for grid actions, per butterball + +## v0.10.3 (2024-05-10) + +### Fix + +- fix bug with grid date filters + +## v0.10.2 (2024-05-08) + +### Feat + +- remove version restriction for pyramid_beaker dependency + +- rename some attrs etc. for buefy components used with oruga + +- fix "tools" helper for receiving batch view, per oruga + +- more data type fixes for ``<tailbone-datepicker>`` + +- fix "view receiving row" page, per oruga + +- tweak styles for grid action links, per butterball + +### Fix + +- fix employees grid when viewing department (per oruga) + +- fix login "enter" key behavior, per oruga + +- fix button text for autocomplete + +## v0.10.1 (2024-04-28) + +### Feat + +- sort list of available themes + +- update various icon names for oruga compatibility + +- show "View This" button when cloning a record + +- stop including 'falafel' as available theme + +### Fix + +- fix vertical alignment in main menu bar, for butterball + +- fix upgrade execution logic/UI per oruga + +## v0.10.0 (2024-04-28) + +This version bump is to reflect adding support for Vue 3 + Oruga via +the 'butterball' theme. There is likely more work to be done for that +yet, but it mostly works at this point. + +### Feat + +- misc. template and view logic tweaks (applicable to all themes) for + better patterns, consistency etc. + +- add initial support for Vue 3 + Oruga, via "butterball" theme + + +## Older Releases + +Please see `docs/OLDCHANGES.rst` for older release notes. diff --git a/CHANGES.rst b/CHANGES.rst deleted file mode 100644 index 4da87f52..00000000 --- a/CHANGES.rst +++ /dev/null @@ -1,2540 +0,0 @@ - -CHANGELOG -========= - -0.6.45 (2017-11-01) -------------------- - -* Add product and personnel flags for Department - -* Add sorters, filters for Product regular, current price - -* Add "text" type for new form fields - -* Add description, notes for pricing batches - - -0.6.44 (2017-10-29) -------------------- - -* Fix join bug for Upgrades table when sorting by executor - - -0.6.43 (2017-10-29) -------------------- - -* Add "make user" button when viewing person w/ no user account - - -0.6.42 (2017-10-28) -------------------- - -* Add cashier info, upload time for Trainwreck transaction views - - -0.6.41 (2017-10-25) -------------------- - -* Add support for validator and required flag, for new forms - -* Use master3 view for datasync changes - - -0.6.40 (2017-10-24) -------------------- - -* Add grid filter which treats empty string as NULL - -* Fix value auto-selection for enum grid filters - -* Add ``item_id`` to trainwreck views - -* Expose ``Person.users`` relationship (readonly) - - -0.6.39 (2017-10-20) -------------------- - -* Fix bug with products view config - - -0.6.38 (2017-10-19) -------------------- - -* Add "local" datetime renderer for new grids, forms - -* Make CSRF protection optional (but on by default) - -* Convert user feedback mechanism to use modal dialog - -* Add 'active' column to Users table view - -* Add "download row results as CSV" feature to master view - -* Add support for setting default field values on new forms - -* Add 'currency' field type for new forms - -* Allow passing ``None`` to ``Grid.set_joiner()`` - - -0.6.37 (2017-09-28) -------------------- - -* Fix data type/size issue with CSV download - -* Don't set batch input file on creation, if no file exists - -* Add "auto-enhance" select field template for deform - -* Add ability to override schema node for custom deform fields - -* Fix deform widget resource inclusion for master/create template - -* Pass form along to ``before_create_flush()`` in master3 - -* Add "populatable" for master views (populating new objects with progress) - -* Add 'duration' type for new form fields - - -0.6.36 (2017-09-15) -------------------- - -* Fix user field rendering when no person associated - -* Add generic support for downloading list results as CSV - -* Tweak title for master view row template - - -0.6.35 (2017-08-30) -------------------- - -* Fix some bugs for rendering upgrade package diffs - - -0.6.34 (2017-08-18) -------------------- - -* Fix mobile inventory template - -* Add extra perms for creating inventory batch w/ different modes - -* Allow batch execution to require options on a per-batch basis - -* Convert more views to master3: - departments, subdepartments, categories, brands, bouncer, customer groups - -* Override deform template for checkbox field; fix label behavior - -* Show all grid actions by default, if there are 3 or less - -* Use shared logic for executing upgrade - - -0.6.33 (2017-08-16) -------------------- - -* Add ``LocalDateTimeFieldRenderer`` for formalchemy - -* Fix auto-disable button on form submit, per Chrome issues - - -0.6.32 (2017-08-15) -------------------- - -* Add generic changelog link for rattail/tailbone packages - -* Let handler delete files when deleting upgrade - -* Add mechanism for user to bulk-change status for purchase credits - -* Tweak how pyramid config is created during app startup, for tests - -* Fix permission used for mobile receiving item lookup - - -0.6.31 (2017-08-13) -------------------- - -* Add show all vs. show diffs for upgrade packages - -* Add initial support for changelog links for upgrade package diffs - -* Add prev/next buttons when viewing upgrade details - -* Merge 'better' theme into base templates - - -0.6.30 (2017-08-12) -------------------- - -* Make product field renderer allow override of link text rendering - - -0.6.29 (2017-08-11) -------------------- - -* Various tweaks to inventory batch logic (zero-all mode etc.) - -* Fix join bug for users grid - -* Flush session once every 1000 records when bulk-deleting - - -0.6.28 (2017-08-09) -------------------- - -* Fix clone config bug for label batches - - -0.6.27 (2017-08-09) -------------------- - -* Improve inventory support, plus "hiding" person data while still using it - -* Fix encoding bug when reading stdout during upgrade - - -0.6.26 (2017-08-09) -------------------- - -* Add awareness of upgrade exit code, success/fail - -* Add support for cloning an upgrade record - -* Add running display of stdout.log when executing upgrade - - -0.6.25 (2017-08-08) -------------------- - -* Specify ``expire_on_commit`` for tailbone db session - - -0.6.24 (2017-08-08) -------------------- - -* Fix bug which caused new empty worked shift when editing time sheet - - -0.6.23 (2017-08-08) -------------------- - -* Fix bulk-delete for batch rows, allow it for pricing batches - -* Fix permission check for deleting single batch rows - -* Fix numeric filter to allow 3 decimal places by default - - -0.6.22 (2017-08-08) -------------------- - -* Remove unwanted import (which broke versioning) - -* Add some links to employees grid - - -0.6.21 (2017-08-08) -------------------- - -* Refactor progress bars somewhat to allow file-based sessions - -* Fix recipients renderer for email settings grid - -* Improve status tracking for upgrades; add package version diff - - -0.6.20 (2017-08-07) -------------------- - -* Record become/stop root user events - -* Make datasync changes bulk-deletable - -* Add basic support for performing / tracking app upgrades - - -0.6.19 (2017-08-04) -------------------- - -* Record basic user login/logout events - -* Expose UserEvent table in UI - - -0.6.18 (2017-08-04) -------------------- - -* Add progress support for bulk deletion - -* Make tempmon readings bulk-deletable - - -0.6.17 (2017-08-04) -------------------- - -* Various view tweaks - - -0.6.16 (2017-08-04) -------------------- - -* Add auto-links for most grids - -* Fix row highlighting for sources panel on product view - - -0.6.15 (2017-08-03) -------------------- - -* Allow product field renderer to suppress hyperlink - -* Add 'data-uuid' attr for mobile grid list items, if applicable - -* Initial (partial) support for mobile ordering - -* Some tweaks to ordering batch views - -* Fix bug when request.user becomes unattached from session (?) - -* Add view for consuming new batch ID - -* Add some links to various grid columns - -* Fix bug in master view_row - - -0.6.14 (2017-08-01) -------------------- - -* Make login template use same logo as home page - -* Fix how we detect grid settings presence in user session - -* Improve verbiage for exception view - -* Fix styles for message compose template - -* Various improvements to batch worksheets, index links etc. - -* Fix batch links when viewing purchase object - -* Add "on order" count to products grid, tweak product notes panel - - -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 - -* Stop allowing pre-0.7 SQLAlchemy - - -0.6.11 (2017-07-18) ------------------- - -* Tweak some basic styles for forms/grids - -* Add new v3 master with v2 forms, with colander/deform - - -0.6.10 (2017-07-18) ------------------- - -* Fix grid bug if "current page" becomes invalid - - -0.6.9 (2017-07-15) ------------------- - -* Expose version history for all supported tables - - -0.6.8 (2017-07-14) ------------------- - -* Provide default renderers for SA mapped tables, where possible - -* Add flexible grid class for v3 grids for width=half etc. - -* Final grid refactor; we now have just 'grids' :) - -* Refactor (coalesce) all batch-related templates - - -0.6.7 (2017-07-14) ------------------- - -* Fix master view ``get_effective_data()`` for v3 grids - - -0.6.6 (2017-07-14) ------------------- - -* Fix bug for printing one-off product labels - - -0.6.5 (2017-07-14) ------------------- - -* Fix template/styles for v3 grid views, add purchasing batch status - - -0.6.4 (2017-07-14) ------------------- - -* Add new "v3" grids, refactor all views to use them - - -0.6.3 (2017-07-13) ------------------- - -* Sort mobile receiving batches by ID desc - -* Add initial/basic support for "simple" mobile grid filter w/ radio buttons - -* Add filter support for mobile row grid; plus mark receiving as complete - -* Disable unused Clear button for mobile receiving - -* Add logic for mobile receiving if product not in batch and/or system - -* Prevent mobile receiving actions for batch which is complete or executed - -* Fix bug with mobile receiving UPC lookup; require stronger "create row" perm - -* Stop using popup for expiration date, for mobile receiving - -* Add global key handler for mobile receiving, for scanner wedge input - -* Make all batches support mobile by default - -* Add basic support for viewing inventory batches on mobile - -* Refactor keypad widget for mobile receiving - -* Add unit cost for inventory batches - - -0.6.2 (2017-07-10) ------------------- - -* Fix CS/EA bug for mobile receiving - - -0.6.1 (2017-07-07) ------------------- - -* Switch license to GPL v3 (no longer Affero) - -* Fix broken product image tag, per webhelpers2 - - -0.6.0 (2017-07-06) ------------------- - -Main reason for bumping version is the (re-)addition of data versioning support -using SQLAlchemy-Continuum. This feature has been a long time coming and while -not yet fully implemented, we have a significant head start. - -* Add custom default grid row size for Trainwreck items - -* Make hyperlink optional for employee field renderer - -* Tweak how customer/person relationships are displayed - -* Add initial support for expiration date for mobile receiving - -* Make Person.employee field readonly - -* Rearrange some imports to ensure ``rattail.db.model`` comes last - -* Add basic versioning history support for master view - -* Remove old-style continuum version views - -* Remove all "old-style" (aka. version 1) grids - -* Remove all old-style views: grids, CRUD, versions etc. - -* Refactor to use webhelpers2 etc. instead of older 'webhelpers' - - -0.5.104 (2017-06-22) --------------------- - -* Add basic views for Trainwreck transactions - -* Add ``AlchemyLocalDateTimeFilter`` - -* Add row count as available column to batch header grids - -* Try to keep batch status updated; display it for handheld batches - -* Tweak display of inventory/label batches to reflect multiple handheld batches - -* Add way to execute multiple handheld batches (search results) at once - -* Fix batch row count when deleting a row - -* Make case/unit quantities prettier within Inventory batch rows grid - -* Sort (alphabetically) device type list field when making new handheld batch - -* Allow bulk row deletion for vendor catalog batches - - -0.5.103 (2017-06-05) --------------------- - -* Always add key as class to grid column headers; allow literal label - - -0.5.102 (2017-05-30) --------------------- - -* Remove all views etc. for old-style batches - -* Fix bug when updating Order Form data, if row.po_total is None - - -0.5.101 (2017-05-25) --------------------- - -* Fix subtle bug when identifying purchase batch row on order form update - -* Remove references to deprecated batch handler methods - -* Add validation for unique name when creating new Setting - -* Simplify page title display for mobile base template - -* Refactor "purchasing" batch views, split off "ordering" - -* Add initial (full-ish) support for mobile receiving views - -* Add support for bulk-delete of Pricing Batches - -* Pad session timeout warning by 10 seconds, to account for drift - -* Add highlight to active row within Order Form view - -* Make 'notes' field use textarea renderer by default, for all batches - -* Add basic ability to download Ordering Batch as Excel spreadsheet - - -0.5.100 (2017-05-18) --------------------- - -* Allow batch view to override execution failure message - -* Tweak some customer view/field rendering, to allow more customization - -* Remove customer view template (use master default) - -* Add basic support for Trainwreck database connectivity - -* Remove unused 'fake_error' view - -* Add basic 'robots.txt' support to CommonView - -* Cap our pyramid_tm version until we can upgrade to pyramid 1.9 - -* Add daily hour totals when viewing or editing single employee time sheet - -* Let config cause time sheet hours to display as HH.HH for some users - -* Expose full-time flag and start date for employee view - -* Add convenience ``dialog_button()`` JS function - - -0.5.99 (2017-05-05) -------------------- - -* Add allowance for Escape key, in numeric.js - -* Let a batch disallow bulk-deletion of its rows - -* Add basic support for deletion speedbump for row data - -* Remove lower version for Pyramid dependency, but restrict to pre-1.9 - - -0.5.98 (2017-04-18) -------------------- - -* Auto-save time sheet day editor on Enter press if time field is focused - -* Add simple flag to prevent multiple submits for Order Form AJAX - - -0.5.97 (2017-04-04) -------------------- - -* Fix signature for ``MasterView.get_index_url()`` - - -0.5.96 (2017-04-04) -------------------- - -* Tweak logic for registering exception view, to avoid test breakage - -* Add basic paging grid/index support for mobile - -* Tweak field label styles for mobile - -* Allow config to define home page image URL - - -0.5.95 (2017-03-29) -------------------- - -* Tweak organization panel for product view template - -* Add logic to core View class, to force logout if user becomes inactive - -* Detect "backwards" shift when time sheet is edited, alert user - -* Add default view for unhandled exceptions, configure only for production - -* Add basic table listing view, with rough estimate row counts - -* Add 'status' column to vendor cost table in product view - -* Various template standardization tweaks - - -0.5.94 (2017-03-25) -------------------- - -* Add ``CostFieldRenderer`` and tweak product view template - -* Bump margin between grid and header table, i.e. buttons - -* Broad refactor to improve customization of purchase order form etc. - -* Fix route sequence for people autocomplete - -* Fix bugs when checking for 'chuck' in demo mode - -* Add unit item and pack size fields to product view - - -0.5.93 (2017-03-22) -------------------- - -* Add 'is_any' verb to integer grid filters - -* Add more variations of project name when creating via scaffold - -* Various tweaks to the customer and person views/forms - -* Add basic "mobile index" master view, plus support for demo mode - -* Refactor the batch file field renderer somewhat - -* Move ``notfound()`` method to core ``View`` class - -* Add ``BatchMasterView.add_file_field()`` convenience method - -* Add ``extra_main_fields()`` method to product view template - -* Allow config to override jQuery UI version - -* Add master view for Report Output data model - - -0.5.92 (2017-03-14) -------------------- - -* Tweak grid configuration for Employees view - -* Add trailing '?' for employee time sheet when hours are incomplete - - -0.5.91 (2017-03-03) -------------------- - -* Add 'discontinued' flag to product view - - -0.5.90 (2017-03-01) -------------------- - -* Add notes, ingredients to product view - - -0.5.89 (2017-02-24) -------------------- - -* Expose/honor per-role session timeouts - -* Fix daylight savings bug when cloning schedule from previous week - -* Expose notes field for purchasing batches - -* Add some product flags (kosher vegan etc.) to view fieldset - -* Add initial support for native product images - - -0.5.88 (2017-02-21) -------------------- - -* Fix session reference bug in schedule view - - -0.5.87 (2017-02-21) -------------------- - -* Fix bug in DateFieldRenderer when no format specified - - -0.5.86 (2017-02-21) -------------------- - -* Add initial/basic views for customer orders data - -* Be less aggressive when validating schedule edit form POST - - -0.5.85 (2017-02-19) -------------------- - -* Add generic "bulk delete" support to MasterView - -* Add beginnings of mobile receiving views - - -0.5.84 (2017-02-17) -------------------- - -* Tweak progress template to better handle reset to 0% - -* Add ability to merge 2 user accounts - -* Increase size of Roles select when editing a User - -* Add ability to filter Sent Messages by recipient name - - -0.5.83 (2017-02-16) -------------------- - -* Set form id for new purchasing batch page - -* Make sure invoice number is saved when making new purchasing batch - -* Tweak product view page styles (new grids etc.) - -* Add support for client-side session timeout warning - - -0.5.82 (2017-02-14) -------------------- - -* Collapse grid actions if there are only 2 - -* Add master view for generic exports - -* Make some product fields readonly - -* Make datasync changes viewable - -* Redirect to login page when Forbidden happens with anonymous user - -* Tweak styles for Send Message page - -* Tweak form handling for sending a new message, for more customization - -* Advance to password field when Enter pressed on username, login page - -* Add way for ``login_user()`` to set different timeout depending on nature of login - - -0.5.81 (2017-02-11) -------------------- - -* Add config for redirecting user to home page after logout - -* Refactor logic used to login a user, for easier sharing - -* Use ``pretty_hours()`` function where applicable - - -0.5.80 (2017-02-10) -------------------- - -* Tweak renderer for Amount field for DepositLink view - -* Tweak how regular/current price fields are handled for Product view - -* Fix bug in base 'shifts' template if ``weekdays`` not in context - - -0.5.79 (2017-02-09) -------------------- - -* Tweak product view template per rename of case_size field - -* Refactor the Edit Time Sheet view for "autocommit" mode - -* Don't render user field as hyperlink unless so configured - -* Expose 'delay' field in tempmon client views - -* Fix bug when first entry is empty for product on ordering form - - -0.5.78 (2017-02-08) -------------------- - -* Add initial Find Roles/Users by Permission feature - -* Fix sorting bug for Employee Time Sheet view - - -0.5.77 (2017-02-04) -------------------- - -* Invoke timepicker to correct format of user input, for edit schedule/timesheet - - -0.5.76 (2017-02-04) -------------------- - -* Add hyperlink to ``EmployeeFieldRenderer`` - -* Improve the grid for ``WorkedShift`` model a bit - -* Add config flag for disabling option to "Clear Schedule" - - -0.5.75 (2017-02-03) -------------------- - -* Fix probe filter for tempmon readings grid - -* Be explicit about fieldset for pricing batch rows - -* Let project override user authentication for login page - -* Add basic support for per-user session timeout - - -0.5.74 (2017-01-31) -------------------- - -* Refactor schedule / timesheet views for better separation of concerns - - -0.5.73 (2017-01-30) -------------------- - -* Add pyramid_mako dependency, remove minimum version for rattail - -* Add ability to edit employee time sheet - -* Add 'target' kwarg for grid action links - -* Add hyperlink to User field renderer - -* Add min diff threshold param when making price batch from product query - -* Add way for batch views to hide rows with given status code(s) - - -0.5.72 (2017-01-29) -------------------- - -* Add basic support for cloning batches - -* Tweaks to order form template etc., for purchasing batch - -* Let master view with rows prevent sort/filter for row grid - -* Add price diff column to pricing batch row grid - -* Add warning highlight for pricing batch row if can't calculate price - - -0.5.71 (2017-01-24) -------------------- - -* Improve columns, filters for TempMon Readings grid - -* Add ability to merge subdepartments - - -0.5.70 (2017-01-11) -------------------- - -* Fix CSRF token bug with email preview form, refactor to use webhelpers - - -0.5.69 (2017-01-06) -------------------- - -* When making batch from products, build query *before* starting thread - - -0.5.68 (2017-01-03) -------------------- - -* Prefer received quantities over ordered quantities, for Order Form history - - -0.5.67 (2017-01-03) -------------------- - -* Add department UUID to JSON returned for "eligible purchases" when creating batch - -* Set "order date" when creating new receiving batch - -* Add "discarded" flag when receiving DMG/EXP products; add view for purchase credits - -* Fix type error in grid numeric filter - - -0.5.66 (2016-12-30) -------------------- - -* Tweak the "create" screen for purchase batches, for more customization - - -0.5.65 (2016-12-29) -------------------- - -* Fix purchase batch execution, to redirect to Purchase *or* Batch - -* Add extra perms for restricing which 'mode' of purchase batch user can create - -* Refactor Order Form a bit to allow custom history data - - -0.5.64 (2016-12-28) -------------------- - -* Tweak default "numeric" grid filter, to ignore UPC-like values - -* Tweak default filter label for Batch ID - - -0.5.63 (2016-12-28) -------------------- - -* Fix CSRF token bug for bulk-move message forms - - -0.5.62 (2016-12-22) -------------------- - -* Fix CSRF token bug for old-style batch params form - - -0.5.61 (2016-12-21) -------------------- - -* Fix master merge template/forms to include CSRF token - - -0.5.60 (2016-12-20) -------------------- - -* Fix CSRF bug in Ordering Form template, make case quantity pretty - -* Fix some bugs in product view template - -* Update some enum references, render all purchase/batch cases/units fields as quantity - - -0.5.59 (2016-12-19) -------------------- - -* Add ``QuantityFieldRenderer`` - -* Add style for 'half-width' grid - - -0.5.58 (2016-12-16) -------------------- - -* Add ``ValidGPC`` formencode validator - -* Overhaul the Receiving Form to account for "product not found" etc. - -* Auto-append slash to URL when necessary - -* Add "print receiving worksheet" feature, for 'ordered' purchases - -* Add global CSRF protection - -* Tweak some field renderers - -* Overhaul product views a little, per customization needs - - -0.5.57 (2016-12-12) -------------------- - -* Lots of changes for sake of mobile login / user menu etc. - -* Add mobile support for datasync restart - -* Make ``CurrencyFieldRenderer`` inherit from ``FloatFieldRenderer`` - -* Fix session bug in old CRUD views - - -0.5.56 (2016-12-11) -------------------- - -* Show 'enabled' column in grid, fix prefix bug for email profiles - -* Tweak flash message when sending email preview, in case it's disabled - -* Hide first/last name for employee view, unless in readonly mode - -* Add initial mobile templates: base, home, about - - -0.5.55 (2016-12-10) -------------------- - -* Validate for unique tempmon probe config key - -* Add 'restartable tempmon client' conditional logic - - -0.5.54 (2016-12-10) -------------------- - -* Add new 'receiving form' for purchase batches - -* Add support for 'department' field in purchases / batches - -* Add generic 'not on file' product image for use as POD 404 - -* Add logic for handling Ctrl+V / Ctrl+X in numeric.js - - -0.5.53 (2016-12-09) -------------------- - -* Fix bug when editing a data row - - -0.5.52 (2016-12-08) -------------------- - -* Fix permission group label for email bounces - -* Update footer text/link per new about page - - -0.5.51 (2016-12-07) -------------------- - -* Fix permission / grid action bug for email profiles - - -0.5.50 (2016-12-07) -------------------- - -* Tweak tempmon views a little, fix client restart logic - -* Add 'extra_styles' to true base template - -* Add new "bytestring" filter for grids that need it - - -0.5.49 (2016-12-05) -------------------- - -* Allow delete for datasync changes - -* Fix import bugs with tempmon views - -* Use master view's session when creating form - - -0.5.48 (2016-12-05) -------------------- - -* Tweak email config views, to support subject "templates" - -* Refactor tempmon views to leverage rattail-tempmon database - - -0.5.47 (2016-11-30) -------------------- - -* Fix bug in products view class - - -0.5.46 (2016-11-29) -------------------- - -* Add basic 'about' page with some package versions - -* Tweak fields for product view - - -0.5.45 (2016-11-28) -------------------- - -* Fix styles for 'print schedule' page - -* Add permission for bulk-delete of batch data rows - - -0.5.44 (2016-11-22) -------------------- - -* Add some links between employees / people / customers views - -* Add support for pricing batches - -* Add initial views for tempmon clients/probes/readings - - -0.5.43 (2016-11-21) -------------------- - -* Add support for receive/cost mode, purchase relation for purchase batches - -* Bump jquery version - -* Fix bug when downloading batch file - - -0.5.42 (2016-11-20) -------------------- - -* Move ``get_batch_kwargs()`` to ``BatchMasterView`` - - -0.5.41 (2016-11-20) -------------------- - -* Add printer-friendly view for "full" employee schedule - -* Fix some bugs etc. with batch views and templates - - -0.5.40 (2016-11-19) -------------------- - -* Add size, extra link fields to product view template - -* Refactor batch views / templates per rattail framework overhaul - - -0.5.39 (2016-11-14) -------------------- - -* Make POD image for product view a bit more sane - -* Disable save button when creating new object - - -0.5.38 (2016-11-11) -------------------- - -* Tweak default factory for boolean grid filters - -* Add support for more cases + units, more vendor fields, for new purchase batches - - -0.5.37 (2016-11-10) -------------------- - -* Display sequence for product alt codes - -* Change how we determine default 'grid key' for master views - -* Add 'additive fields' concept to merge diff preview - - -0.5.36 (2016-11-09) -------------------- - -* Add historical amounts to new purchase Order Form, allow extra columns etc. - -* Tweak verbiage for merge template etc. - - -0.5.35 (2016-11-08) -------------------- - -* Add support for new Purchase/Batch views, 'create row' master pattern - -* Add basic views for label batches - -* Add support for making new-style batches from products grid query - -* Add initial support for viewing new purchase batch as Order Form - -* Refactor how batch editing is done; don't include rows for that sometimes - - -0.5.34 (2016-11-02) -------------------- - -* Add basic merge feature to ``MasterView`` - - -0.5.33 (2016-10-27) -------------------- - -* Fix template bug when deleting user - -* Tweak default styles for home page - -* Show vendor invoice rows as warning, if they have no case quantity - -* Add 'vendor code' and 'vendor code (any)' filters for products grid - -* Fix bug with how we auto-filter 'deleted' products (?) - - -0.5.32 (2016-10-19) -------------------- - -* Fix / improve progress display somewhat - -* Disable "true delete" button by default, when clicked - -* Fix bug in batch ID field renderer, when displayed for new batch - -* Add ``refresh_after_create`` flag for ``BatchMasterView`` - -* Disable a focus() call in menubar.js which messed with search filter focus - -* Let any 'admin' user elevate to 'root' for full system access - -* Update references to ``request.authenticated_userid`` - - -0.5.31 (2016-10-14) -------------------- - -* Add ability to edit employee schedule - - -0.5.30 (2016-10-10) -------------------- - -* Tweak some things to make demo project more "out of the box" - -* Add registration for 'rattail' template with Pyramid scaffold system - -* Add 'tailbone' to global template context, update 'better' template footer - -* Tweak how tailbone finds rattail config from pyramid settings - -* Remove last references to 'edbob' package - -* Strip whitespace from username field when editing User - -* Fix couple of bugs for vendor catalog views - -* Add size description to inventory report - - -0.5.29 (2016-10-04) -------------------- - -* Add ``code`` field to Category views - -* Add "bulk delete rows" feature to new batches view - - -0.5.28 (2016-09-30) -------------------- - -* Add specific permissions for edit/delete of individual batch rows - - -0.5.27 (2016-09-26) -------------------- - -* Add basic form validation when sending new messages - -* Add "just in time" editable instance check for master view - -* Add "refresh" button when viewing batch - -* Add FormAlchemy-compatible validators for email address, phone number - -* Improve validation for FormAlchemy date field renderer - -* Fix row-level visibility for grid edit action - -* Add a couple of extra verbs to base grid filter class - -* Tweak how a grid filter factory is determined - - -0.5.26 (2016-09-01) -------------------- - -* Add ``MasterView.listable`` flag for disabling grid view - -* Fix permission group label bug for batch views - -* Allow opt-out for "download batch row data as CSV" feature - - -0.5.25 (2016-08-23) -------------------- - -* Tweak how we use DB session to fetch grid settings - -* Add "sub-rows" support to MasterView class - -* Refactor batch views to leverage MasterView sub-rows logic - -* Refactor batch view/edit pages to share some "execution options" logic - -* Add hook to customize timesheet shift rendering - - -0.5.24 (2016-08-17) -------------------- - -* Fix bug in handheld batch view config - - -0.5.23 (2016-08-17) -------------------- - -* Fix bug when viewing batch with no execution options - - -0.5.22 (2016-08-17) -------------------- - -* Fix bug for handheld batch device type field - - -0.5.21 (2016-08-17) -------------------- - -* Add ``MasterView.render()`` method for sake of common context/logic - -* Add "empty" option to enum field renderers, if field allows empty value - -* Add support for system-unique ID in batch views etc. - -* Fix bug when deleting certain batches - -* Fix bug in batch download URL - -* Add basic support for batch execution options - -* Add basic support for new handheld/inventory batches - - -0.5.20 (2016-08-13) -------------------- - -* Add null / not null verbs back to default boolean grid filter - - -0.5.19 (2016-08-12) -------------------- - -* Only show granted permissions when viewing role details - -* Expose 'enabled' flag for email profile/settings - -* Add permissions field when viewing user details - - -0.5.18 (2016-08-10) -------------------- - -* Add ``render_progress()`` method to core view class - -* Add hopefully generic ``FileFieldRenderer`` - - -0.5.17 (2016-08-09) -------------------- - -* Add support for 10-key hyphen/period keys for numeric input fields - - -0.5.16 (2016-08-05) -------------------- - -* Fallback to empty string for email preview recipient, if current user has no address - -* Allow negative sign, decimal point for "numeric" text fields - - -0.5.15 (2016-07-27) -------------------- - -* Add initial attempt at 'better' theme - -* Add ``CodeTextAreaFieldRenderer``, refactor label profile form to use it - - -0.5.14 (2016-07-08) -------------------- - -* Allow extra kwargs to core ``View.redirect()`` method - -* Add awareness of special 'Authenticated' role, in permissions UI etc. - -* Always strip whitespace from label profile 'spec' field input - - -0.5.13 (2016-06-10) -------------------- - -* Hopefully fix some CSS for form field values - -* Add support for viewing single employee's schedule / time sheet - - -0.5.12 (2016-05-11) -------------------- - -* Add support for "full" schedule and time sheet views. - -* Move "full name" to front of Person grid columns. - -* Add rattail config object to ``Session`` kwargs. - - -0.5.11 (2016-05-06) -------------------- - -* Refactor some common FormEncode validators, plus add some more. - -* Tweak styles for jQuery UI selectmenu dropdowns. - -* Tweak timesheet styles, to give rows alternating background color. - -* Disable autocomplete for password fields when editing user. - -* Various incomplete improvements to the timesheet/schedule views. - - -0.5.10 (2016-05-05) -------------------- - -* Refactor timesheet logic, add basic schedule view. - -* Add prev/next/jump week navigation to time sheet, schedule views. - -* Add hyperlinks to product UPC and description, within main grid. - -* Fix bug in roles view. - - -0.5.9 (2016-05-02) ------------------- - -* Remove 'create batch from results' link on products index page. - -* Fix bugs in batch grid URLs. - -* Tweak how empty hours are displayed in time sheet. - - -0.5.8 (2016-05-02) ------------------- - -* Add ``MasterView.listing`` flag, for templates' sake. - -* Overhaul newgrid template header a bit, to improve styles. - -* Move ``Person.display_name`` to top of fieldset when viewing/editing. - -* Add 'testing' image, for background / watermark. - -* Add 'index title' setting to master view. - -* Add auto-hide/show magic to message recipients field when viewing. - -* Add initial support for grid index URLs. - -* Add initial/basic user feedback form support. - -* Stop trying to use PIL when generating product image tag. - - -0.5.7 (2016-04-28) ------------------- - -* Add master views for ``ScheduledShift`` model. - -* Add initial (incomplete) Time Sheet view. - - -0.5.6 (2016-04-25) ------------------- - -* Add views for ``WorkedShift`` model. - - -0.5.5 (2016-04-24) ------------------- - -* Add workarounds for certain display bugs when rendering datetimes. - -* Make currency field renderer display negative amounts in parentheses. - -* Add commas to record/page count in grid footer. - -* Tweak styles for form field labels. - - -0.5.4 (2016-04-12) ------------------- - -* Add support for column header title (tooltip) in new grids. - -* Change default filter type for integer fields, in new grids. - -* Add flag for rendering key value, for enum field renderers. - -* Fix case-sensitivity when sorting permission group labels. - - -0.5.3 (2016-04-05) ------------------- - -* Fix redirect bug when attempting bulk row delete for nonexistent batch. - -* Add comma magic back to ``CurrencyFieldRenderer``. - -* Add the 'is any' verb to default list for most grid filters. - -* Add new ``TimeFieldRenderer``, make it default for ``Time`` fields. - -* Add last-minute check to ensure master views allows deletion. - - -0.5.2 (2016-03-11) ------------------- - -* Make ``tailbone.views.labels`` a subpackage instead of module. - -* Add 'executed' to old batches grid view. - -* Make all timestamps show "raw" by default (with "diff" tooltip). - -* Improve grid filters for datetime fields (smarter verbs). - -* Fix bug where batch creator was being set to current user anytime it was viewed..yikes. - - -0.5.1 (2016-02-27) ------------------- - -* Fix bug when rendering email bounce links. - - -0.5.0 (2016-02-15) ------------------- - -* Refactor products view(s) per new master pattern. - -* Make our ``DateTimeFieldRenderer`` the default for datetime fields. - -* Add new ``BatchMasterView`` for new-style batches. - -* Overhaul vendor catalogs, vendor invoices views to use new batch master class. - -* Refactor some more model views to use MasterView. (depositlink, tax, emailbounce) - -* Make datasync views easier to customize. - - -0.4.42 ------- - -* Add initial reply / reply-all support for messages. - -* Add subscriber hook for setting inbox count in template context. - - -0.4.41 ------- - -* Tweak how we connect a user to a batch, when refreshing. - -* Add 'Move' button to message view template. - - -0.4.40 ------- - -* Make rattail config object use our scoped session, when consulting db. - - -0.4.39 ------- - -* Add support for sending new messages. - - -0.4.38 ------- - -* Add 'password is/not null' filter to users list view. - -* Remove style hack for message grid views. - - -0.4.37 ------- - -* Add 'messages.list' permission, to protect inbox etc. - - -0.4.36 ------- - -* Fix bug when marking batch as executed. - - -0.4.35 ------- - -* Change default form buttons so Cancel is also a button. - -* Add 'Stores' and 'Departments' fields to Employee fieldset. - - -0.4.34 ------- - -* Add 'restart datasync' button to datasync changes list page. - -* Add autocomplete vendor field renderer. - -* Change vendor catalog upload, to allow vendor-less parsers. - -* Stop depending on PIL...for now? - - -0.4.33 ------- - -* Add employee/department relationships to employee and department views. - - -0.4.32 ------- - -* Add edit mode for email "profile" settings. - -* Fix auto-creation of grid sorter, when joined table is involved. - -* Add initial support for 'messages' views. - - -0.4.31 ------- - -* Add speed bump / confirmation page when deleting records. - -* Add "grid tools" to "complete" grid template. - -* Add ``Person.middle_name`` to the fieldset. - - -0.4.30 ------- - -* Add config extension, to record data changes if so configured. - -* Add mailing address to person fieldset. - - -0.4.29 ------- - -* Fix some route names. - - -0.4.28 ------- - -* Use sample data when generating subject for display in email profile settings. - -* Convert (most?) basic views to use master view pattern. - - -0.4.27 ------- - -* Change default sortkey for email profiles list. - -* Add 'To' field to email profile settings grid. - - -0.4.26 ------- - -* Add readonly support for email profile settings. - - -0.4.25 ------- - -* Fix bug when 'edbob.permissions' setting is empty. - -* Tweak some things to get Tailbone working on its own. - -* Let subclass of MasterView override the database Session it uses. - - -0.4.24 ------- - -* Render ``DataSyncChange.obtained`` as humanized timestamp within UI. - - -0.4.23 ------- - -* Delete product costs for vendor when deleting vendor. - -* Work around formalchemy config bug, caused by edbob. - -* Add view to show DataSync changes, for basic troubleshooting. - - -0.4.22 ------- - -* Remove format hack which isn't py2.6-friendly. - - -0.4.21 ------- - -* Add "valueless verbs" concept to grid filters. - -* Tweak labels for new grid filter form buttons. - -* Configure logging when starting up. - -* Add HTML5 doctype to base template. - -* More grid filter improvements; add choice/enum/date value renderers. - -* Treat filter by "contains X Y" as "contains X and contains Y". - -* Tweak layout CSS so page body expands to fill screen. - - -0.4.20 ------- - -* Add ``CurrencyFieldRenderer``. - -* Add basic checkbox support to new grids. - -* Add 'Default Filters' and 'Clear Filters' buttons to new grid filters form. - -* Add "Save Defaults" button so user can save personal defaults for any new grid. - -* Fix bug when rendering hidden field in FA fieldset. - -* Remove some unused styles. - -* Various tweaks to support "late login" idea when uploading new batch. - -* Hard-code old grid pagecount settings, to avoid ``edbob.config``. - -* Refactor app configuration to use ``rattail.config.make_config()``. - -* Tweak label formatter instantiation, per rattail changes. - -* Various tweaks to base batch views. - -* Add ``CustomFieldRenderer`` and ``DateFieldRenderer``. - -* Add ``configure_fieldset()`` stub for master view. - -* Add progress indicator to batch execution. - -* Add ability to download batch row data as CSV. - - -0.4.19 ------- - -* Fix progress template, per jQuery CDN changes. - - -0.4.18 ------- - -* Don't show flash message when user logs in. - -* Add core JS/CSS to base template; use CDN instead of cached files. - -* Add support for "new-style grids" and "model master views", and convert the - following views to use it: roles, users, label profiles, settings. Also - overhaul how permissions are registered in app config. - - -0.4.17 ------- - -* Log warning instead of error when refreshing batch fails. - - -0.4.16 ------- - -* Add initial support for email bounce management. - - -0.4.15 ------- - -* Fix missing import bug. - - -0.4.14 ------- - -* Make anchor tags with 'button' class render as jQuery UI buttons. - -* Tweak ``app.make_rattail_config()`` to allow caller to define some settings. - -* Add ``display_name`` field to employee CRUD view. - -* Allow batch handler to disable the Execute button. - -* Add ``StoreFieldRenderer`` and ``DecimalFieldRenderer``. - -* Tweak how default filter config is handled for batch grid views. - -* Add list of assigned users to role view page. - -* Add products autocomplete view. - -* Add ``rattail_config`` attribute to base ``View`` class. - -* Fix timezone issues with ``util.pretty_datetime()`` function. - -* Add some custom FormEncode validators. - - -0.4.13 ------- - -* Fix query bugs for batch row grid views (add join support). - -* Make vendor field renderer show ID in readonly mode. - -* Change permission requirement for refreshing a batch's data. - -* Add flash message when any batch executes successfully. - -* Add autocomplete view for current employees. - -* Add autocomplete employee field renderer. - -* Fix usage of ``Product.unit_of_measure`` vs. ``Product.weighed``. - - -0.4.12 ------- - -* Fix bug when creating batch from product query. - - -0.4.11 ------- - -* Tweak old-style batch execution call. - - -0.4.10 ------- - -* Add 'fake_error' view to test exception handling. - -* Add ability to view details (i.e. all fields) of a batch row. - -* Fix bulk delete of batch rows, to set 'removed' flag instead. - -* Fix vendor invoice validation bug. - -* Add dept. number and friends to product details page. - -* Add "extra panels" customization hook to product details template. - - -0.4.9 ------ - -* Hide "print labels" column on products list view if so configured. - - -0.4.8 ------ - -* Fix permission for deposit link list/search view. - -* Fix permission for taxes list/search view. - - -0.4.7 ------ - -* Add views for deposit links, taxes; update product view. - -* Add some new vendor and product fields. - -* Add panels to product details view, etc. - -* Fix login so user is sent to their target page after authentication. - -* Don't allow edit of vendor and effective date in catalog batches. - -* Add shared GPC search filter, use it for product batch rows. - -* Add default ``Grid.iter_rows()`` implementation. - -* Add "save" icon and grid column style. - -* Add ``numeric.js`` script for numeric-only text inputs. - -* Add product UPC to JSON output of 'products.search' view. - - -0.4.6 ------ - -* Add vendor catalog batch importer. - -* Add vendor invoice batch importer. - -* Improve data file handling for file batches. - -* Add download feature for file batches. - -* Add better error handling when batch refresh fails, etc. - -* Add some docs for new batch system. - -* Refactor ``app`` module to promote code sharing. - -* Force grid table background to white. - -* Exclude 'deleted' items from reports. - -* Hide deleted field from product details, according to permissions. - -* Fix embedded grid URL query string bug. - - -0.4.5 ------ - -* Add prettier UPCs to ordering worksheet report. - -* Add case pack field to product CRUD form. - - -0.4.4 ------ - -* Add UI support for ``Product.deleted`` column. - - -0.4.3 ------ - -* More versioning support fixes, to allow on or off. - - -0.4.2 ------ - -* Rework versioning support to allow it to be on or off. - - -0.4.1 ------ - -* Only attempt to count versions for versioned models (CRUD views). - - -0.4.0 ------ - -This version primarily got the bump it did because of the addition of support -for SQLAlchemy-Continuum versioning. There were several other minor changes as -well. - -* Add department to field lists for category views. - -* Change default sort for People grid view. - -* Add category to product CRUD view. - -* Add initial versioning support with SQLAlchemy-Continuum. - - -0.3.28 ------- - -* Add unique username check when creating users. - -* Improve UPC search for rows within batches. - -* New batch system... - - -0.3.27 ------- - -* Fix bug with default search filters for SA grids. - -* Fix bug in product search UPC filter. - -* Ugh, add unwanted jQuery libs to progress template. - -* Add support for integer search filters. - - -0.3.26 ------- - -* Use boolean search filter for batch column filters of 'FLAG' type. - - -0.3.25 ------- - -* Make product UPC search view strip non-digit chars from input. - - -0.3.24 ------- - -* Make ``GPCFieldRenderer`` display check digit separate from main barcode - data. - -* Add ``DateTimeFieldRenderer`` to show human-friendly timestamps. - -* Tweak CRUD form buttons a little. - -* Add grid, CRUD views for ``Setting`` model. - -* Update ``base.css`` with various things from other projects. - -* Fix bug with progress template, when error occurs. - - -0.3.23 ------- - -* Fix bugs when configuring database session within threads. - - -0.3.22 ------- - -* Make ``Store.database_key`` field editable. - -* Add explicit session config within batch threads. - -* Remove cap on installed Pyramid version. - -* Change session progress API. - - -0.3.21 ------- - -* Add monospace font for label printer format command. - - -0.3.20 ------- - -* Refactor some label printing stuff, per rattail changes. - - -0.3.19 ------- - -* Add support for ``Product.not_for_sale`` flag. - - -0.3.18 ------- - -* Add explicit file encoding to all Mako templates. - -* Add "active" filter to users view; enable it by default. - - -0.3.17 ------- - -* Add customer phone autocomplete and customer "info" AJAX view. - -* Allow editing ``User.active`` field. - -* Add Person autocomplete view which restricts to employees only. - - -0.3.16 ------- - -* Add product report codes to the UI. - - -0.3.15 ------- - -* Add experimental soundex filter support to the Customers grid. - - -0.3.14 ------- - -* Add event hook for attaching Rattail ``config`` to new requests. - -* Fix vendor filter/sort issues in products grid. - -* Add ``Family`` and ``Product.family`` to the general grid/crud UI. - -* Add POD image support to product view page. - - -0.3.13 ------- - -* Use global ``Session`` from rattail (again). - -* Apply zope transaction to global Tailbone Session class. - - -0.3.12 ------- - -* Fix customer lookup bug in customer detail view. - -* Add ``SessionProgress`` class, and ``progress`` views. - - -0.3.11 ------- - -* Removed reliance on global ``rattail.db.Session`` class. - - -0.3.10 ------- - -* Changed ``UserFieldRenderer`` to leverage ``User.display_name``. - -* Refactored model imports, etc. - - This is in preparation for using database models only from ``rattail`` - (i.e. no ``edbob``). Mostly the model and enum imports were affected. - -* Removed references to ``edbob.enum``. - - -0.3.9 ------ - -* Added forbidden view. - -* Fixed bug with ``request.has_any_perm()``. - -* Made ``SortableAlchemyGridView`` default to full (100%) width. - -* Refactored ``AutocompleteFieldRenderer``. - - Also improved some organization of renderers. - -* Allow overriding form class/factory for CRUD views. - -* Made ``EnumFieldRenderer`` a proper class. - -* Don't sort values in ``EnumFieldRenderer``. - - The dictionaries used to supply enumeration values should be ``OrderedDict`` - instances if sorting is needed. - -* Added ``Product.family`` to CRUD view. - - -0.3.8 ------ - -* Fixed manifest (whoops). - - -0.3.7 ------ - -* Added some autocomplete Javascript magic. - - Not sure how this got missed the first time around. - -* Added ``products.search`` route/view. - - This is for simple AJAX uses. - -* Fixed grid join map bug. - - -0.3.6 ------ - -* Fixed change password template/form. - - -0.3.5 ------ - -* Added ``forms.alchemy`` module and changed CRUD view to use it. - -* Added progress template. - - -0.3.4 ------ - -* Changed vendor filter in product search to find "any vendor". - - I.e. the current filter is *not* restricted to the preferred vendor only. - Probably should still add one (back) for preferred only as well; hence the - commented code. - - -0.3.3 ------ - -* Major overhaul for standalone operation. - - This removes some of the ``edbob`` reliance, as well as borrowing some - templates and styling etc. from Dtail. - - Stop using ``edbob.db.engine``, stop using all edbob templates, etc. - -* Fix authorization policy bug. - - This was really an edge case, but in any event the problem would occur when a - user was logged in, and then that user account was deleted. - -* Added ``global_title()`` to base template. - -* Made logo more easily customizable in login template. - - -0.3.2 ------ - -* Rebranded to Tailbone. - - -0.3.1 ------ - -* Added some tests. - -* Added ``helpers`` module. - - Also added a Pyramid subscriber hook to add the module to the template - renderer context with a key of ``h``. This is nothing really new, but it - overrides the helper provided by ``edbob``, and adds a ``pretty_date()`` - function (which maybe isn't a good idea anyway..?). - -* Added ``simpleform`` wildcard import to ``forms`` module. - -* Added autocomplete view and template. - -* Fixed customer group deletion. - - Now any customer associations are dropped first, to avoid database integrity - errors. - -* Stole grids and grid-based views from ``edbob``. - -* Removed several references to ``edbob``. - -* Replaced ``Grid.clickable`` with ``.viewable``. - - Clickable grid rows seemed to be more irritating than useful. Now a view - icon is shown instead. - -* Added style for grid checkbox cells. - -* Fixed FormAlchemy table rendering when underlying session is not primary. - - This was needed for a grid based on a LOC SMS session. - -* Added grid sort arrow images. - -* Improved query modification logic in alchemy grid views. - -* Overhauled report views to allow easier template customization. - -* Improved product UPC search so check digit is optional. - -* Fixed import issue with ``views.reports`` module. - - -0.3a23 ------- - -* Fixed bugs where edit links were appearing for unprivileged users. - -* Added support for product codes. - - These are shown when viewing a product, and may be used to locate a product - via search filters. - - -0.3a22 ------- - -* Removed ``setup.cfg`` file. - -* Added ``Session`` to ``rattail.pyramid`` namespace. - -* Added Email Address field to Vendor CRUD views. - -* Added extra key lookups for customer and product routes. - - Now the CRUD routes for these objects can leverage UUIDs of various related - objects in addition to the primary object. More should be done with this, - but at least we have a start. - -* Replaced ``forms`` module with subpackage; added some initial goodies (many - of which are currently just imports from ``edbob``). - -* Added/edited various CRUD templates for consistency. - -* Modified several view modules so their Pyramid configuration is more - "extensible." This just means routes and views are defined as two separate - steps, so that derived applications may inherit the route definitions if they - so choose. - -* Added Employee CRUD views; added Email Address field to index view. - -* Updated ``people`` view module so it no longer derives from that of - ``edbob``. - -* Added support for, and some implementations of, extra key lookup abilities to - CRUD views. This allows URLs to use a "natural" key (e.g. Customer ID - instead of UUID), for cases where that is more helpful. - -* Product CRUD now uses autocomplete for Brand field. Also, price fields no - longer appear within an editable fieldset. - -* Within Store index view, default sort is now ID instead of Name. - -* Added Contact and Phone Number fields to Vendor CRUD views; added Contact and - Email Address fields to index view. - - -0.3a21 ------- - -- [feature] Added CRUD view and template. - -- [feature] Added ``AutocompleteView``. - -- [feature] Added Person autocomplete view and User CRUD views. - -- [feature] Added ``id`` and ``status`` fields to Employee grid view. - - -0.3a20 ------- - -- [feature] Sorted the Ordering Worksheet by product brand, description. - -0.3a19 ------- - -- [feature] Made batch creation and execution threads aware of - `sys.excepthook`. Updated both instances to use `rattail.threads.Thread` - instead of `threading.Thread`. This way if an exception occurs within the - thread, the registered handler will be invoked. - -0.3a18 ------- - -- [bug] Label profile editing now uses stripping field renderer to avoid - problems with leading/trailing whitespace. - -- [feature] Added Inventory Worksheet report. - -0.3a17 ------- - -- [feature] Added Brand and Size fields to the Ordering Worksheet. Also - tweaked the template styles slightly, and added the ability to override the - template via config. - -- [feature] Added "preferred only" option to Ordering Worksheet. - -0.3a16 ------- - -- [bug] Fixed bug where requesting deletion of non-existent batch row was - redirecting to a non-existent route. - -0.3a15 ------- - -- [bug] Fixed batch grid and CRUD views so that the execution time shows a - pretty (and local) display instead of 24-hour UTC time. - -0.3a14 ------- - -- [feature] Added some more CRUD. Mostly this was for departments, - subdepartments, brands and products. This was rather ad-hoc and still is - probably far from complete. - -- [general] Changed main batch route. - -- [bug] Fixed label profile templates so they properly handle a missing or - invalid printer spec. - -0.3a13 ------- - -- [bug] Fixed bug which prevented UPC search from working on products screen. - -0.3a12 ------- - -- [general] Fixed namespace packages, per ``setuptools`` documentation. - -- [feature] Added support for ``LabelProfile.visible``. This field may now be - edited, and it is honored when displaying the list of available profiles to - be used for printing from the products page. - -- [bug] Fixed bug where non-numeric data entered in the UPC search field on the - products page was raising an error. - -0.3a11 ------- - -- [bug] Fixed product label printing to handle any uncaught exception, and - report the error message to the end user. - -0.3a10 ------- - -- [general] Updated category views and templates. These were sorely out of - date. - -0.3a9 ------ - -- Add brands autocomplete view. - -- Add departments autocomplete view. - -- Add ID filter to vendors grid. - -0.3a8 ------ - -- Tweak batch progress indicators. - -- Add "Executed" column, filter to batch grid. - -0.3a7 ------ - -- Add ability to restrict batch providers via config. - -0.3a6 ------ - -- Add Vendor CRUD. - -- Add Brand views. - -0.3a5 ------ - -- Added support for GPC data type. - -- Added eager import of ``rattail.sil`` in ``before_render`` hook. - -- Removed ``rattail.pyramid.util`` module. - -- Added initial batch support: views, templates, creation from Product grid. - -- Added support for ``rattail.LabelProfile`` class. - -- Improved Product grid to include filter/sort on Vendor. - -- Cleaned up dependencies. - -- Added ``rattail.pyramid.includeme()``. - -- Added ``CustomerGroup`` CRUD view (read only). - -- Added hot links to ``Customer`` CRUD view. - -- Added ``Store`` index, CRUD views. - -- Updated ``rattail.pyramid.views.includeme()``. - -- Added ``email_preference`` to ``Customer`` CRUD. - -0.3a4 ------ - -- Update grid and CRUD views per changes in ``edbob``. - -0.3a3 ------ - -- Add price field renderers. - -- Add/tweak lots of views for database models. - -- Add label printing to product list view. - -- Add (some of) ``Product`` CRUD. - -0.3a2 ------ - -- Refactor category views. - -0.3a1 ------ - -- Initial port to Rattail v0.3. diff --git a/MANIFEST.in b/MANIFEST.in index 984d2491..a3d57f93 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,7 @@ include *.txt include *.rst include *.py +include tailbone/static/robots.txt recursive-include tailbone/static *.js recursive-include tailbone/static *.css recursive-include tailbone/static *.png @@ -10,5 +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/docs/OLDCHANGES.rst b/docs/OLDCHANGES.rst new file mode 100644 index 00000000..0a802f40 --- /dev/null +++ b/docs/OLDCHANGES.rst @@ -0,0 +1,7539 @@ + +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) +-------------------- + +* Cleanup form display a bit, for App Settings. + +* Invoke the auth handler to cache user permissions etc. + + +0.8.161 (2021-10-13) +-------------------- + +* Add ``debounce()`` wrapper for buefy autocomplete. + +* Leverage the auth handler for main user login. + + +0.8.160 (2021-10-11) +-------------------- + +* Stop rounding case/unit cost fields to 2 places for purchase batch. + +* Fix some phone/email bugs for new custorder page. + +* Fix bug when making context for mailing address. + +* Improve display, handling for "add contact info to customer record". + + +0.8.159 (2021-10-10) +-------------------- + +* Simplify template context customization for view_profile_buefy. + + +0.8.158 (2021-10-07) +-------------------- + +* Add support for "new customer" when creating new custorder. + +* Improve contact name handling for new custorder. + + +0.8.157 (2021-10-06) +-------------------- + +* Some tweaks for invoice costing batch views. + +* Add "restrict contact info" features for new custorder batch. + +* Add "contact update request" workflow for new custorder batch. + + +0.8.156 (2021-10-05) +-------------------- + +* Show "contact notes" when creating new custorder. + +* Improve phone editing for new custorder. + +* Add button to refresh contact info for new custorder. + +* Overhaul the "Personal" tab of profile view. + +* Refactor the Employee tab of profile view, per better patterns. + + +0.8.155 (2021-10-01) +-------------------- + +* Refactor autocomplete view logic to leverage new "autocompleters". + + +0.8.154 (2021-09-30) +-------------------- + +* Initial (basic) views for invoice costing batches. + + +0.8.153 (2021-09-28) +-------------------- + +* Improve phone/email handling when making new custorder. + +* Avoid "detach person" logic if not supported by view class. + + +0.8.152 (2021-09-27) +-------------------- + +* Allow changing status, adding notes for customer order items. + + +0.8.151 (2021-09-27) +-------------------- + +* Overhaul new custorder so contact may be either Person or Customer. + +* Add a dropdown of choices to the Department filter for Products grid. + + +0.8.150 (2021-09-26) +-------------------- + +* Refactor several "field grids" per Buefy theme. + +* Display the Store field for Customer Orders. + + +0.8.149 (2021-09-25) +-------------------- + +* Improve default autocomplete query logic, w/ multiple ILIKE. + +* Add placeholder to customer lookup for new order. + +* Invoke handler for customer autocomplete when making new custorder. + +* Improve "employees" list when viewing a department, for buefy themes. + +* Add products row grid for misc. org table views. + + +0.8.148 (2021-09-22) +-------------------- + +* Add way to update Employee ID from profile view. + + +0.8.147 (2021-09-22) +-------------------- + +* Add way to override grid action label rendering. + + +0.8.146 (2021-09-21) +-------------------- + +* Misc. improvements for customer order views. + + +0.8.145 (2021-09-19) +-------------------- + +* Allow setting the "exclusive" sequence of grid filters. + + +0.8.144 (2021-09-16) +-------------------- + +* Invoke handler when request is made to merge 2 people. + + +0.8.143 (2021-09-12) +-------------------- + +* Add way to customize product autocomplete for new custorder. + + +0.8.142 (2021-09-09) +-------------------- + +* Set quantity type when viewing vendor lead times, order intervals. + + +0.8.141 (2021-09-09) +-------------------- + +* Add /people API endpoint; allow for "native sort". + +* Allow override of "create" permission in API. + +* Add the ``Grid.remove()`` method, deprecate ``hide_column()`` etc. + +* Improve error handling for purchase batch. + + +0.8.140 (2021-09-01) +-------------------- + +* Make it easier to override rendering grid component in master/index. + +* Always show all grid actions...for now. + +* Allow grid columns to be *invisible* (but still present in grid). + +* Improve UI, customization hooks for new custorder batch. + +* Add hover text for vendor ID column of pricing batch row grid. + +* Fix size of roles multi-select when editing user. + +* Allow "touch" action for employees. + + +0.8.139 (2021-08-26) +-------------------- + +* Tweak how email preview is sent, and attempt "to" is displayed. + +* Move "merge 2 people" logic into People Handler. + +* Expose "merge request tracking" feature for People data. + +* Allow customization of row 'view' action url. + +* Require explicit opt-in for "clicking grid row checks box" feature. + +* Add ``before_render_index()`` customization hook for MasterView. + + +0.8.138 (2021-08-04) +-------------------- + +* Let feedback forms define their own email key. + + +0.8.137 (2021-07-15) +-------------------- + +* Set UPC renderer for delproduct batch row. + +* Expose ``pack_size`` for delproduct batch. + + +0.8.136 (2021-06-18) +-------------------- + +* Include "is/not null" filters for GPC fields. + + +0.8.135 (2021-06-15) +-------------------- + +* Add 'v' prefix for release package diff links. + + +0.8.134 (2021-06-15) +-------------------- + +* Allow config to set favicon and header image. + + +0.8.133 (2021-06-11) +-------------------- + +* Allow customization of rendering version diff values. + +* Allow direct creation of new label batches. + +* Allow generating project which integrates w/ LOC SMS. + + +0.8.132 (2021-05-03) +-------------------- + +* Highlight "has inventory" rows for delete item batch. + +* Add csrftoken to TailboneForm js. + +* Freeze pyramid version at 1.x. + + +0.8.131 (2021-04-12) +-------------------- + +* Show current price date range as hover text, for products grid. + +* Make it easier to extend "common" API views. + +* Accept any decimal numbers for API inventory batch counts. + + +0.8.130 (2021-03-30) +-------------------- + +* Catch and show error, if one happens when making batch from product query. + +* Expose the new ``Store.archived`` flag. + + +0.8.129 (2021-03-11) +-------------------- + +* Add support for ``inactivity_months`` field for delete product batch. + +* Expose new fields for Trainwreck. + +* Fix enum display for customer order status. + + +0.8.128 (2021-03-05) +-------------------- + +* Allow per-user stylesheet for Buefy themes. + +* Expose ``date_created`` for delete product batches. + + +0.8.127 (2021-03-02) +-------------------- + +* Use end time as default filter, sort for Trainwreck. + +* Avoid encoding values as string, for integer grid filters. + +* Fix message recipients for Reply / Reply-All, with Buefy themes. + +* Handle row click as if checkbox was clicked, for checkable grid. + +* Highlight delete product batch rows with "pending customer orders" status. + +* Add hover text for subdepartment name, in pricing batch row grid. + + +0.8.126 (2021-02-18) +-------------------- + +* Allow customization of main Buefy CSS styles, for falafel theme. + +* Add special "contains any of" verb for string-based grid filters. + +* Add special "equal to any of" verb for UPC-related grid filters. + +* Tweaks per "delete products" batch. + +* Misc. tweaks for vendor catalog batch. + +* Add support for "default" trainwreck model. + + +0.8.125 (2021-02-10) +-------------------- + +* Fix some permission bugs when showing batch tools etc. + +* Render batch execution description as markdown. + +* Cleanup default display for vendor catalog batches. + +* Make errors more obvious, when running batch commands as subprocess. + +* Add styles for field labels in profile view. + + +0.8.124 (2021-02-04) +-------------------- + +* Fix bug when editing a Person. + + +0.8.123 (2021-02-04) +-------------------- + +* Fix config defaults for PurchaseView. + +* Add stub methods for ``MasterView.template_kwargs_view()`` etc. + +* Update references to vendor catalog batches etc. + +* Fix display of handheld batch links, when viewing label batch. + +* Prevent updates to batch rows, if batch is immutable. + + +0.8.122 (2021-02-01) +-------------------- + +* Normalize naming of all traditional master views. + +* Undo recent ``base.css`` changes for ``<p>`` tags. + +* Misc. improvements for ordering batches, purchases. + +* Purge things for legacy (jquery) mobile, and unused template themes. + +* Make handler responsible for possible receiving modes. + +* Split "new receiving batch" process into 2 steps: choose, create. + +* Add initial "scanning" feature for Ordering Batches. + +* Add support for "nested" menu items. + +* Add icon for Help button. + + +0.8.121 (2021-01-28) +-------------------- + +* Tweak how vendor link is rendered for readonly field. + +* Use "People Handler" to update names, when editing person or user. + + +0.8.120 (2021-01-27) +-------------------- + +* Initial support for adding items to, executing customer order batch. + +* Add changelog link for Theo, in upgrade package diff. + +* Hide "collect from wild" button for UOMs unless user has permission. + + +0.8.119 (2021-01-25) +-------------------- + +* Don't create new person for new user, if one was selected. + +* Allow newer zope.sqlalchemy package. + +* Add variant transaction logic per zope.sqlalchemy 1.1 changes. + +* Add CSS styles for 'codehilite' a la Pygments. + +* Add feature to generate new features... + +* Add views for "delete product" batch. + +* Set ``self.model`` when constructing new View. + +* Add some generic render methods to MasterView. + +* Add custom ``base.css`` for falafel theme. + +* Add master view for Units of Measure mapping table. + +* Add woocommerce package links for sake of upgrade diff view. + +* Add basic web API app, for simple use cases. + + +0.8.118 (2021-01-10) +-------------------- + +* Show node title in header for Login, About pages. + +* Allow changing protected user password when acting as root. + +* Allow specifying the size of a file, for ``readable_size()`` method. + +* Try to show existing filename, for upload widget. + +* Add basic support for "download" and "rawbytes" API views. + + +0.8.117 (2020-12-16) +-------------------- + +* Add common "form poster" logic, to make CSRF token/header names configurable. + +* Refactor the feedback form to use common form poster logic. + + +0.8.116 (2020-12-15) +-------------------- + +* Add basic views for IFPS PLU Codes. + +* Add very basic support for merging 2 People. + +* Tweak spacing for header logo + title, in falafel theme. + + +0.8.115 (2020-12-04) +-------------------- + +* Add the "Employee Status" filter to People grid. + +* Add "is empty" and related verbs, for "string" type grid filters. + +* Assume composite PK when fetching instance for master view. + + +0.8.114 (2020-12-01) +-------------------- + +* Misc. tweaks to vendor catalog views. + +* Tweak how an "enum" grid filter is initialized. + +* Add "generic" Employee tab feature, for profile view. + + +0.8.113 (2020-10-13) +-------------------- + +* Tweak how global DB session is created. + + +0.8.112 (2020-09-29) +-------------------- + +* Add support for "list" type of app settings (w/ textarea). + +* Add feature to "download rows for results" in master index view. + +* Fix "refresh results" for batches, in Buefy theme. + + +0.8.111 (2020-09-25) +-------------------- + +* Allow alternate engine to act as 'default' when multiple are available. + +* Fix grid bug when paginator is not involved. + + +0.8.110 (2020-09-24) +-------------------- + +* Add ``user_is_protected()`` method to core View class. + +* Change how we protect certain person, employee records. + +* Add global help URL to login template. + +* Fix bug when fetching partial versions data grid. + + +0.8.109 (2020-09-22) +-------------------- + +* Add 'warning' class for 'delete' action in b-table grid. + +* Add "worksheet file" pattern for editing batches. + +* Avoid unhelpful error when perm check happens for "re-created" DB user. + +* Prompt user if they try to send email preview w/ no address. + +* Don't expose "timezone" for input when generating 'fabric' project. + +* Add some more field hints when generating 'fabric' project. + +* Show node title in header, for home page. + +* Remove unwanted columns for default Products grid. + + +0.8.108 (2020-09-16) +-------------------- + +* Allow custom props for TailboneForm component. + +* Remove some custom field labels for Vendor. + +* Add support for generating new 'fabric' project. + + +0.8.107 (2020-09-14) +-------------------- + +* Stop including 'complete' filter by default for purchasing batches. + +* Overhaul project changelog links for upgrade pkg diff table. + +* Add support/views for generating new custom projects, via handler. + + +0.8.106 (2020-09-02) +-------------------- + +* Add progress for generating "results as CSV/XLSX" file to download. + +* Use utf8 encoding when downloading results as CSV. + +* Add new/flexible "download results" feature. + +* Fix spacing between components in "grid tools" section. + +* Add support for batch execution options in Buefy themes. + +* Improve auto-handling of "local" timestamps. + +* Expose ``Product.average_weight`` field. + + +0.8.105 (2020-08-21) +-------------------- + +* Tweaks for export views, to make more generic. + +* Add config for "global" help URL. + +* Remove ``<section>`` tag around "no results" for minimal b-table. + +* Allow for unknown/missing "changed by" user for product price history. + +* Add buefy theme support for ordering worksheet. + +* Don't require department by default, for new purchasing batch. + + +0.8.104 (2020-08-17) +-------------------- + +* Make "download row results" a bit more generic. + +* Add pagination to price, cost history grids for product view. + + +0.8.103 (2020-08-13) +-------------------- + +* Tweak config methods for customer master view. + + +0.8.102 (2020-08-10) +-------------------- + +* Improve rendering of ``true_margin`` column for pricing batch row grid. + + +0.8.101 (2020-08-09) +-------------------- + +* Fix missing scrollbar when version diff table is too wide for screen. + +* Add basic web views for "new customer order" batches. + +* Tweak the buefy autocomplete component a bit. + +* Add basic/unfinished "new customer order" page/feature. + +* Add ``protected_usernames()`` config function. + +* Add ``model`` to global template context, plus ``h.maxlen()``. + +* Coalesce on ``User.active`` when merging. + +* Expose user reference(s) for employees. + + +0.8.100 (2020-07-30) +-------------------- + +* Add more customization hooks for making grid actions in master view. + + +0.8.99 (2020-07-29) +------------------- + +* Add ``self.cloning`` convenience indicator for master view. + +* Use handler ``do_delete()`` method when deleting a batch. + + +0.8.98 (2020-07-26) +------------------- + +* Tweak field label for ``Product.item_id``. + +* Make field list explicit for Department views. + +* Make field list explicit for Store views. + +* Don't allow "execute results" for any batches by default. + +* Fix pagination sync issue with buefy grid tables. + +* Fix permissions wiget bug when creating new role. + +* Tweak "coalesce" logic for merging field data. + + +0.8.97 (2020-06-24) +------------------- + +* Add dropdown, autohide magic when editing Role permissions. + +* Add ability to download roles / permissions matrix as Excel file. + +* Improve support for composite key in master view. + +* Use byte string filters for row grid too. + +* Convert mako directories to list, if it's a string. + + +0.8.96 (2020-06-17) +------------------- + +* Don't allow edit/delete of rows, if master view says so. + + +0.8.95 (2020-05-27) +------------------- + +* Cap version for 'cornice' dependency. + +* Let each grid component have a custom name, if needed. + + +0.8.94 (2020-05-20) +------------------- + +* Expose "shelved" field for pricing batches. + +* Sort available reports by name, if handler doesn't specify. + + +0.8.93 (2020-05-15) +------------------- + +* Parse pip requirements file ourselves, instead of using their internals. + +* Don't auto-include "Guest" role when finding roles w/ permission X. + + +0.8.92 (2020-04-07) +------------------- + +* Allow the home page to include quickie search. + + +0.8.91 (2020-04-06) +------------------- + +* Add "danger" style for "delete" grid row action. + +* Misc. API improvements for sake of mobile receiving. + +* Use proper cornice service registration, for API batch execute etc. + +* Add common permission for sending user feedback. + +* Fix the "change password" form per Buefy theme. + +* Expose the ``Role.notes`` field for view/edit. + +* Add "local only" column to Users grid. + +* Fix row status filter for Import/Export batches. + +* Add "generic" ``render_id_str()`` method to MasterView. + +* Stop raising an error if view doesn't define row grid columns. + +* Add helper function, ``get_csrf_token()``. + +* Add support for "choice" widget, for report params. + +* Allow bulk-delete, merge for Brands table. + +* Move inventory batch view to its proper location. + +* Allow bulk-delete for Inventory Batches. + +* Move "most" inventory batch logic out of view, to underlying handler. + +* Add initial API views for inventory batches. + +* Add basic dashboard page for TempMon. + +* Let config totally disable the old/legacy jQuery mobile app. + +* Defer fetching price, cost history when viewing product details. + + +0.8.90 (2020-03-18) +------------------- + +* Add basic "ordering worksheet" API. + +* Tweak GPC grid filter, to better handle spaces in user input. + +* Only show tables for "public" schema. + +* Remove old/unwanted Vue.js index experiment, for Users table. + +* Misc. changes to User, Role permissions and management thereof. + +* Don't let user delete roles to which they belong, without permission. + +* Prevent deletion of department which still has products. + +* Add sort/filter for Department Name, in Subdepartments grid. + +* Allow "touch" for Department, Subdepartment. + +* Expose ``Customer.number`` field. + +* Add support for "bulk-delete" of Person table. + +* Allow customization for Customers tab of Profile view. + +* Expose default email address, phone number when editing a Person. + +* Add/improve various display of Member data. + + +0.8.89 (2020-03-11) +------------------- + +* Refactor "view profile" page per latest Buefy theme conventions. + +* Move logic for Order Form worksheet into purchase batch handler. + +* Make sure all contact info is "touched" when touching person record. + + +0.8.88 (2020-03-05) +------------------- + +* Fix batch row status breakdown for Buefy themes. + +* Add support for refreshing multiple batches (results) at once. + +* Remove "api." prefix for default route names, in API master views. + +* Allow "touch" for vendor records. + + +0.8.87 (2020-03-02) +------------------- + +* Add new "master" API view class; refactor products and batches to use it. + +* Refactor all API views thus far, to use new v2 master. + +* Use Cornice when registering all "service" API views. + + +0.8.86 (2020-03-01) +------------------- + +* Add toggle complete, more normalized row fields for odering batch API. + +* Return employee_uuid along with user info, from API. + +* Add support for executing ordering batches via API. + +* Fix how we fetch employee history, for profile view. + +* Cleanup main version history views for Buefy theme. + +* Fix product price, cost history dialogs, for Buefy theme. + +* Fix some basic product editing features. + + +0.8.85 (2020-02-26) +------------------- + +* Overhaul the /ordering batch API somewhat; update docs. + +* Tweak ``save_edit_row_form()`` of purchase batch view, to leverage handler. + +* Tweak ``worksheet_update()`` of ordering batch view, to leverage handler. + +* Fix "edit row" logic for ordering batch. + +* Raise 404 not found instead of error, when user is not employee. + +* Send batch params as part of normalized API. + + +0.8.84 (2020-02-21) +------------------- + +* Add API view for changing current user password. + +* Return new user permissions when logging in via API. + + +0.8.83 (2020-02-12) +------------------- + +* Use new ``Email.obtain_sample_data()`` method when generating preview. + +* Add some custom display logic for "current price" in pricing batch. + +* Fix email preview for TXT templates on python3. + +* Allow override of "email key" for user feedback, sent via API. + +* Add way to prevent user login via API, per custom logic. + +* Add common ``get_user_info()`` method for all API views. + +* Return package names as list, from "about" page from API. + + +0.8.82 (2020-02-03) +------------------- + +* Fix vendor ID/name for Excel download of pricing batch rows. + +* Add red highlight for SRP breach, for generic product batch. + +* Make sure falafel theme is somewhat available by default. + + +0.8.81 (2020-01-28) +------------------- + +* Include regular price changes, for current price history dialog. + +* Allow populate of new pricing batch from products w/ "SRP breach". + +* Tweak how we import pip internal things, for upgrade view. + +* Sort report options by name, when choosing which to generate. + +* Add warning for "price breaches SRP" rows in pricing batch. + + +0.8.80 (2020-01-20) +------------------- + +* Hide the SRP history link for new buefy themes. + +* Add regular price history dialog for product view. + +* Add support for Row Status Breakdown, for Import/Export batches. + +* Cleanup "diff" table for importer batch row view, per Buefy theme. + +* Highlight SRP in red, if reg price is greater. + +* Expose batch ID, sequence for datasync change queue. + +* Add "current price history" dialog for product view. + +* Add "cost history" dialog for product view. + + +0.8.79 (2020-01-06) +------------------- + +* Move "delete results" logic for master grid. + + +0.8.78 (2020-01-02) +------------------- + +* Add ``Grid.set_filters_sequence()`` convenience method. + +* Add dialog for viewing product SRP history. + + +0.8.77 (2019-12-04) +------------------- + +* Use currency formatting for costs in vendor catalog batch. + + +0.8.76 (2019-12-02) +------------------- + +* Allow update of row unit cost directly from receiving batch view. + +* Show vendor item code in receiving batch row grid. + +* Expose catalog cost, allow updating, for receiving batch rows. + +* Add API view for marking "receiving complete" for receiving batch. + +* Allow override of user authentication logic for API. + +* Add API views for admin user to become / stop being "root". + + +0.8.75 (2019-11-19) +------------------- + +* Filter by receiving mode, for receiving batch API. + + +0.8.74 (2019-11-15) +------------------- + +* Add support for label batch "quick entry" API. + +* Add support for "toggle complete" for batch API. + +* Add some API views for receiving, and vendor autocomplete. + +* Move "quick entry" logic for purchase batch, into rattail handler. + +* Provide background color when first checking API session. + + +0.8.73 (2019-11-08) +------------------- + +* Assume "local only" flag should be ON by default, for new objects. + +* Bump default Buefy version to 0.8.2. + +* Always store CSRF token for each page in Vue.js theme. + +* Refactor "make batch from products query" per Vue.js theme. + +* Add Vue.js support for "enable / disable selected" grid feature. + +* Add Vue.js support for "delete selected" grid feature. + +* Improve checkbox click handling support for grids. + +* Improve/fix some views for Messages per Vue.js theme. + +* Add some padding above/below form fields (for Vue.js). + +* Use "warning" status for pricing batch rows, where product not found. + +* Refactor "send new message" form, esp. recipients field, per Vue.js. + +* Allow rendering of "raw" datetime as ISO date. + +* Add very basic API views for label batches. + +* Fallback to referrer if form has no cancel button URL. + +* Fix merge feature for master index grid. + + +0.8.72 (2019-10-25) +------------------- + +* Allow bulk delete of New Product batch rows. + +* Don't bug out if can't update roles for user. + + +0.8.71 (2019-10-23) +------------------- + +* Improve default behavior for clone operation. + +* Add config flag to "force unit item" for inventory batch. + +* Fix JS bug for graph view of tempmon probe readings. + + +0.8.70 (2019-10-17) +------------------- + +* Don't bug out if stores, departments fields aren't present for Employee. + + +0.8.69 (2019-10-15) +------------------- + +* Fix buefy grid pager bug. + +* Fix permissions for add/edit/delete notes from people profile view. + + +0.8.68 (2019-10-14) +------------------- + +* Use ``self.has_perm()`` within MasterView. + +* Only show action URL if present, for Buefy grid rows. + +* Show active flag for users mini-grid on Role view page. + + +0.8.67 (2019-10-12) +------------------- + +* Fix URL for user, for feedback email. + +* Add "is false or null" verb for boolean grid filters. + +* Move label batch views to ``tailbone.views.batch.labels``. + +* Allow bulk-delete for some common batches. + +* Move vendor catalog batch views to ``tailbone.views.batch.vendorcatalog``. + +* Expose the "is preferred vendor" flag for vendor catalog batches. + +* Move vendor invoice batch views to ``tailbone.views.batch.vendorinvoice``. + +* Expose unit cost diff for vendor invoice batch rows. + +* Honor configured db key sequence; let config hide some db keys from UI. + + +0.8.66 (2019-10-08) +------------------- + +* Fix label bug for grid filter with value choices dropdown. + + +0.8.65 (2019-10-07) +------------------- + +* Add support for "local only" Person, User, plus related security. + + +0.8.64 (2019-10-04) +------------------- + +* Add ``forbidden()`` convenience method to core View class. + + +0.8.63 (2019-10-02) +------------------- + +* Fix "progress" behavior for upgrade page. + + +0.8.62 (2019-09-25) +------------------- + +* Add core ``View.make_progress()`` method. + + +0.8.61 (2019-09-24) +------------------- + +* Use ``simple_error()`` from rattail, for showing some error messages. + +* Honor kwargs used for ``MasterView.get_index_url()``. + +* Fix progress page so it effectively fetches progress data synchronously. + +* Show "image not found" placeholder image for products which have none. + + +0.8.60 (2019-09-09) +------------------- + +* Show product image from database, if it exists. + +* Let config turn off display of "POD" image from products. + + +0.8.59 (2019-09-09) +------------------- + +* Let a grid have custom ajax data url. + +* Set default max height, width for app logo. + +* Hopefully fix "single store" behavior when make a new ordering batch. + +* Add basic support for create and update actions in API views. + +* Tweak how we detect JSON request body instead of POST params. + +* Add basic support for "between" verb, for date range grid filter. + +* Add basic API view for user feedback. + +* Add basic API view for "about" page. + +* Include ``short_name`` in field list returned by /session API. + +* Return current user permissions when session is checked via API. + +* Tweak return value for /customers API. + +* Cleanup styles for login form. + +* Add /products API endpoint, enable basic filter support for API views. + +* Add basic API endpoints for /ordering-batch. + +* Don't show Delete Row button for executed batch, on jquery mobile site. + +* Include tax1 thru tax3 flags in form fields for product view page. + +* Prevent text wrap for pricing panel fields on product view page. + +* Fix rendering of "handheld batches" field for inventory batch view. + +* Fix various templates for generating reports, per Buefy. + +* Fix 'about' page template for Buefy themes. + + +0.8.58 (2019-08-21) +------------------- + +* Provide today's date as context for profile view. + +* Tweak login page logo style for jQuery (non-Buefy) themes. + + +0.8.57 (2019-08-05) +------------------- + +* Remove unused "login tips" for demo. + +* Fix form handling for user feedback. + +* Fix "last sold" field rendering for product view. + + +0.8.56 (2019-08-04) +------------------- + +* Fix home and login pages for Buefy theme. + + +0.8.55 (2019-08-04) +------------------- + +* Allow "touch" for Person records. + +* Refactor Buefy templates to use WholePage and ThisPage components. + +* Highlight former Employee records as red/warning. + + +0.8.54 (2019-07-31) +------------------- + +* Freeze Buefy version at pre-0.8.0. + + +0.8.53 (2019-07-30) +------------------- + +* Add proper support for composite primary key, in MasterView. + + +0.8.52 (2019-07-25) +------------------- + +* Add 'disabled' prop for Buefy datepicker. + +* Add perm for editing employee history from profile view. + +* Add "multi-engine" support for Trainwreck transaction views. + +* Cleanup 'phone' filter/sort logic for Employees grid. + + +0.8.51 (2019-07-13) +------------------- + +* Add basic "DB picker" support, for views which allow multiple engines. + +* Include employee history data in context for "view profile". + +* Add custom permissions for People "profile" view. + +* Use latest version of Buefy by default, for falafel theme. + +* Send URL for viewing employee, along to profile page template. + + +0.8.50 (2019-07-09) +------------------- + +* Add way to hide "view profile" helper for customer view. + +* Add ``render_customer()`` method for MasterView. + +* When creating an export, set creator to current user. + +* Add basic "downloadable" support for ExportMasterView. + +* Remove unwanted "export has file" logic for ExportMasterView. + +* Refactor feedback dialog for Buefy themes. + +* Add support for general "view click handler" for ``<b-table>`` element. + + +0.8.49 (2019-07-01) +------------------- + +* Fix product view template per Buefy refactoring. + + +0.8.48 (2019-07-01) +------------------- + +* Clear checked rows when refreshing async grid data. + + +0.8.47 (2019-07-01) +------------------- + +* Allow "touch" for customer records. + +* Add ``NumericInputWidget`` for use with Buefy themes. + +* Expose a way to embed "raw" data values within Buefy grid data. + +* Add 'duration_hours' type for grid column display. + +* Make sure grid action links preserve white-space. + + +0.8.46 (2019-06-25) +------------------- + +* Only expose "Make User" button when viewing a person. + +* Fix PO total calculation bug for mobile ordering. + +* Fix "edit row" icon for batch row grids, for Buefy themes. + +* Refactor all Buefy form submit buttons, per Chrome behavior. + + +0.8.45 (2019-06-18) +------------------- + +* Fix inheritance issue with "view row" master template. + + +0.8.44 (2019-06-18) +------------------- + +* Add generic ``/page.mako`` template. + +* Add Buefy support for "execute results" from core batch grid view. + +* Pull the grid tools to the right, for Buefy. + +* Fix click behavior for all/diffs package links in upgrade view. + +* Refactor form/page component structure for Buefy/Vue.js. + + +0.8.43 (2019-06-16) +------------------- + +* Refactor tempmon probe view template, per Buefy. + +* Refactor tempmon probe graph view per Buefy. + +* Use once-button for tempmon client restart. + +* Fix package diff table for upgrade view template, per Buefy. + +* Assign client IP address to session, for sake of data versioning. + +* Use locale formatting for some numbers in the Buefy grid. + +* Buefy support for "mark batch as (in)complete". + + +0.8.42 (2019-06-14) +------------------- + +* Fix some response headers per python 3. + +* Make person, created by fields readonly when editing Person Note. + + +0.8.41 (2019-06-13) +------------------- + +* Add ``json_response()`` convenience method for all views. + +* Add ``<b-table>`` element template for simple grids with "static" data. + +* Improve props handling for ``<once-button>`` component. + +* Fall back to parsing request body as JSON for form data. + +* Basic support for maintaining PersonNote data from profile view. + +* Fix permissions styles for view/edit of User, Role. + +* Turn on bulk-delete feature for Raw Settings view. + +* Add a generic "user" field renderer to master view. + +* Fix "current value" for ``<b-select>`` element in e.g. edit form views. + +* Use ``<once-button>`` in more places, where appropriate. + +* Update calculated PO totals for purchasing batch, when editing row. + +* Add support for Buefy autocomplete. + +* More Buefy tweaks, for file upload, and "edit batch" generally. + +* Tweak structure of "view product" page to support Buefy, context menu. + +* Add support for "simple confirm" of object deletion. + +* Add some vendor fields for product Excel download. + + +0.8.40 (2019-06-03) +------------------- + +* Add ``verbose`` flag for ``util.raw_datetime()`` rendering. + +* Add basic master view for PersonNote data model. + +* Make email preview buttons use primary color. + +* Add basic Buefy support for batch refresh, execute buttons. + +* Add basic/generic Buefy support to the Form class. + +* Add custom ``tailbone-datepicker`` component for Buefy. + +* Let view template define how to render "row grid tools". + +* Move logic used to determine if current request should use Buefy. + +* Allow inherited theme to set location of Vue.js, Buefy etc. + +* Add "full justify" for grid filter pseudo-column elements. + +* Expose per-page size picker for Buefy grids. + +* Add basic Buefy support for default SelectWidget template. + +* Add Buefy support for enum grid filters. + +* Add ``<once-button>`` component for Buefy templates. + +* Add basic Buefy support for "Make User" button when viewing Person. + +* Make Buefy grids use proper Vue.js component structure. + +* Assume forms support Buefy if theme does; fix basic CRUD views. + +* Fix Buefy "row grids" when viewing parent; add basic file upload support. + +* Refactor "edit printer settings" view for Label Profile. + +* Add Buefy panels support for "view product" page. + +* Allow bulk row delete for generic products batch. + +* also "lots more changes" for sake of Buefy support... + + +0.8.39 (2019-05-09) +------------------- + +* Expose params and type key for report output. + +* Clean up falafel theme, move some parts to root template path. + +* Allow choosing report from simple list, when generating new. + +* Force unicode string behavior for left/right arrow thingies. + +* Must still define "jquery theme" for falafel theme, for now. + +* Add support for "quickie" search in falafel theme. + +* Fix sorting info bug when Buefy grid doesn't support it. + +* Make "view profile" buttons use "primary" color. + +* Add ``simple_field()`` def for base falafel template. + +* Align pseudo-columns for grid filters; let app settings define widths. + +* Tweak how we disable grid filter options. + +* Add basic Buefy form support when generating reports. + +* Add basic/generic email validator logic. + + +0.8.38 (2019-05-07) +------------------- + +* Add basic support for "quickie" search. + +* Add basic Buefy support for row grids. + +* Add basic Buefy support for merging 2 objects. + + +0.8.37 (2019-05-05) +------------------- + +* Add basic Buefy support for full "profile" view for Person. + + +0.8.36 (2019-05-03) +------------------- + +* Add basic support for "touching" a data record object. + + +0.8.35 (2019-04-30) +------------------- + +* Add filter for Vendor ID in Pricing Batch row grid. + +* Pass batch execution kwargs when doing that via subprocess. + + +0.8.34 (2019-04-25) +------------------- + +* Don't assume grid model class declares its title. + + +0.8.33 (2019-04-25) +------------------- + +* Add "most of" Buefy support for grid filters. + +* Add Buefy support for email preview buttons. + +* Improve logic used to determine if current theme supports Buefy. + +* Add basic Buefy support for App Settings page. + +* Add views for "new product" batches. + +* Fix auto-disable action for new message form. + +* Declare row fields for vendor catalog batches. + +* Add "created by" and "executed by" grid filters for all batch views. + +* Expose new code fields for pricing batch. + +* Add basic Buefy support for "find user/role with permission X". + +* Improve default people "profile" view somewhat. + +* Add support for generic "product" batch type. + +* Fix some issues with progress "socket" workaround for batches. + +* Allow config to specify grid "page size" options. + +* Add ``render_person()`` convenience method for MasterView. + + +0.8.32 (2019-04-12) +------------------- + +* Can finally assume "simple" menus by default. + +* Add custom grid filter for phone number fields. + +* Add ``raw_datetime()`` function to ``tailbone.helpers`` module. + +* Add "profile" view, for viewing *all* details of a given person at once. + +* Add "view profile" object helper for all person-related views. + +* Hopefully fix style bug when new filter is added to grid. + + +0.8.31 (2019-04-02) +------------------- + +* Require invoice parser selection for new truck dump child from invoice. + +* Make sure user sees "receive row" page on mobile, after scanning UPC. + +* Use shipped instead of ordered, for receiving authority. + +* Add ``move_before()`` convenience method for ``GridFilterSet``. + + +0.8.30 (2019-03-29) +------------------- + +* Add smarts for some more projects in the upgraded packages links. + +* Add basic "Buefy" support for grids (master index view). + +* Remove 'number' column for Customers grid by default. + +* Add feature for generating new report of arbitrary type and params. + +* Fix rendering bug when ``price.multiple`` is null. + +* Fix HTML escaping bug when rendering products with pack price. + +* Don't allow deletion of some receiving data rows on mobile. + +* Add validation when "declaring credit" for receiving batch row. + +* Add proper hamburger menu for falafel theme. + +* Add icon for Feedback button, in falafel theme. + + +0.8.29 (2019-03-21) +------------------- + +* Allow width of object helper panel to grow. + + +0.8.28 (2019-03-14) +------------------- + +* Tweak how batch handler is invoked to remove row. + +* Add mobile alert when receiving product for 2nd time. + +* Honor enum sort order where possible, for grid filter values. + +* Add basic "receive row" desktop view for receiving batches. + +* Add "declare credit" UI for receiving batch rows. + + +0.8.27 (2019-03-11) +------------------- + +* Fix some unicode literals for base template. + + +0.8.26 (2019-03-11) +------------------- + +* Expose "true cost" and "true margin" columns for products grid. + +* Use configured background color for 'bobcat' theme. + +* Add view, edit links to vue.js users index. + +* Fix navbar, footer background to match custom body background (bobcat theme). + +* Fix layout issues for bobcat theme, so footer sticks to bottom. + +* Fix login page styles for bobcat theme. + +* Refactor template ``content_title()`` and prev/next buttons feature. + +* Add basic 'dodo' theme. + +* Allow apps to set background color per request. + +* Add 'falafel' theme, based on bobcat. + +* Begin to customize grid filters, for 'falafel' theme. + +* Fix PO unit cost calculation for ordering row, batch. + + +0.8.25 (2019-03-08) +------------------- + +* Show grid link even when value is "false-ish". + +* Only objectify address data if present. + +* Improve display of purchase credit data. + +* Expose new "calculated" invoice totals for receiving batch, rows. + + +0.8.24 (2019-03-06) +------------------- + +* Add "plain" date widget. + +* Invoke handler when marking batch as (in)complete. + +* Add new "receive row" view for mobile receiving; invokes handler. + +* Remove 'truck_dump' field from mobile receiving batch view. + +* Add "truck dump status" fields to receiving batch views. + +* Add ability to sort by Credits? column for receiving batch rows. + +* Add mobile support for basic "feedback" dialog. + +* Tweak the "incomplete" row filter for mobile receiving batch. + + +0.8.23 (2019-02-22) +------------------- + +* Add basic support for "mobile edit" of records. + +* Add basic support for editing address for a "contact" record. + +* Add ``unique_id()`` validator method to Customer view. + +* Declare "is contact" for the Customers view. + +* Allow vendor field to be dropdown, for mobile ordering/receiving. + +* Treat empty string as null, for app settings field values. + + +0.8.22 (2019-02-14) +------------------- + +* Improve validator for "percent" input widget. + +* Refactor email settings/preview views to use email handler. + + +0.8.21 (2019-02-12) +------------------- + +* Remove usage of ``colander.timeparse()`` function. + + +0.8.20 (2019-02-08) +------------------- + +* Introduce support for "children first" truck dump receiving. + + +0.8.19 (2019-02-06) +------------------- + +* Add support for downloading batch rows as XLSX file. + + +0.8.18 (2019-02-05) +------------------- + +* Add support for "delete set" feature for main object index view. + +* Use app node title setting for base template. + +* Improve user form handling, to prevent unwanted Person creation. + +* Add support for background color app setting. + +* Add generic support for "enable/disable selection" of grid records. + + +0.8.17 (2019-01-31) +------------------- + +* Improve rendering of ``enabled`` field for tempmon clients, probes. + + +0.8.16 (2019-01-28) +------------------- + +* Update tempmon UI now that ``enabled`` flags are really datetime in DB. + + +0.8.15 (2019-01-24) +------------------- + +* Fix response header value, per python3. + + +0.8.14 (2019-01-23) +------------------- + +* Use empty string for "missing" department name, for ordering worksheet. + + +0.8.13 (2019-01-22) +------------------- + +* Include ``robots.txt`` in the manifest. + + +0.8.12 (2019-01-21) +------------------- + +* Log details of one-off label printing error, when they occur. + +* Fix Excel download of ordering batch, per python3. + + +0.8.11 (2019-01-17) +------------------- + +* Convert all datetime values to localtime, for "download rows as CSV". + + +0.8.10 (2019-01-11) +------------------- + +* Fix products grid query when filter/sort has multiple ProductCost joins. + + +0.8.9 (2019-01-10) +------------------ + +* Tweak batch view template "object helpers" for easier customization. + +* Let batch view customize logic for marking batch as (in)complete. + +* Make command configurable, for restarting tempmon-client. + + +0.8.8 (2019-01-08) +------------------ + +* Add custom widget for "percent" field. + + +0.8.7 (2019-01-07) +------------------ + +* Fix styles for master view_row template. + +* Turn off messaging-related menus by default. + + +0.8.6 (2019-01-02) +------------------ + +* Expose ``vendor_id`` column in pricing batch row grid. + +* Only allow POST method for executing "results" for batch grid. + + +0.8.5 (2019-01-01) +------------------ + +* Add basic master view for Members table. + + +0.8.4 (2018-12-19) +------------------ + +* Add ``object_helpers()`` def to master/view template. + +* Add ``oneoff_import()`` helper method to MasterView class. + +* Fix some styles, per flexbox layout changes. + +* Add ability to make new pricing batch from input data file. + +* Clean up some inventory batch UI logic; prefer units by default. + +* Add 'unit_cost' to Excel download for Products grid. + +* Expose subdepartment for pricing batch rows. + +* Add 'percent' as field type for Form; fix rendering of 'percent' for Grid. + +* Expose label profile selection when editing label batch. + +* Make sure custom field labels are shown for batch execution dialog. + + +0.8.3 (2018-12-14) +------------------ + +* Fix some layout styles for master edit template. + + +0.8.2 (2018-12-13) +------------------ + +* Refactor product view template to use flexbox styles. + + +0.8.1 (2018-12-10) +------------------ + +* Expose new "sync me" flag for LabelProfile settings. + + +0.8.0 (2018-12-02) +------------------ + +This version begins the "serious" efforts in pursuit of REST API, Vue.js, Bulma +and related technologies. + +* Use sqlalchemy-filters package for REST API collection_get. + +* Refactor API collection_get to work with vue-tables-2. + +* Remove some relationship fields when creating new Person. + +* Fix bug in receiving template when truck dump not enabled. + +* Tweak default "model title" logic for master view. + +* Add better support for "make import batch from file" pattern. + +* Fix download filename when it contains spaces. + +* Add "min % diff" option for pricing batch from products query. + +* Allow override of products query when making batch from it. + +* Use empty string instead of null as fallback value, for pricing rows CSV. + +* Add very basic Vue.js grid/index experiment for Users table. + +* Add patterns for joining tables in API list methods. + +* Add template "theme" feature, albeit global. + +* Clean up how we configure DB sessions on app startup. + +* Add description, notes to default form_fields for batch views. + +* Add basic 'excite-bike' theme. + +* Use Bulma CSS and some components for 'bobcat' theme. + +* Add basic support for "simple menus". + +* Refactor default theme re: "context menu" and "object helper" styles. + +* Use 4 decimal places when calculating hours for worked shift excel download. + +* Expose ``old_price_margin`` field for pricing batch rows. + + +0.7.50 (2018-11-19) +------------------- + +* Add simple price fields for product XLSX results download. + +* Add "200 per page" option for UI table grids. + +* Add department, subdepartment "name" columns for products XLSX download. + +* Allow override of template for custom create views. + +* Expose new ``Customer.wholesale`` flag. + +* Add vendor id, name to row CSV download for pricing batch. + +* Expose ``suggested_price``, ``price_diff_percent``, ``margin_diff`` for + pricing batch row. + + +0.7.49 (2018-11-08) +------------------- + +* Detect non-numeric entry when locating row for purchase batch. + +* Remove unwanted style for "email setting description" field. + +* Add ``Grid.hide_columns()`` convenience method. + +* Make sure status field is readonly when creating new batch. + +* Display "suggested price" when viewing product details. + + +0.7.48 (2018-11-07) +------------------- + +* Add initial ``tailbone.api`` subpackage, with some basic API views. Note + that this API is meant to be ran as a separate app so we can better leverage + Cornice features. + +* Add client IP address to user feedback email. + + +0.7.47 (2018-10-25) +------------------- + +* Try to configure the 'pyramid_retry' package, if available. + +* Add more time range options for viewing tempmon probe readings as graph. + +* Add button for restarting filemon. + + +0.7.46 (2018-10-24) +------------------- + +* Allow individual App Settings to not be required; allow null. + +* Add ``MasterView.render_product()``; fix edit for pricing batch row. + +* Add ability to "transform" TD parent row from pack to unit item. + + +0.7.45 (2018-10-19) +------------------- + +* Add very basic support for viewing tempmon probe readings as graph. + + +0.7.44 (2018-10-19) +------------------- + +* Don't include LargeBinary properties in default colander schema. + + +0.7.43 (2018-10-19) +------------------- + +* Add new timeout fields for tempmon probe. + +* Customize template for viewing probe details. + +* Add support for new Tempmon Appliance table, etc. + +* Add basic image upload support for tempmon appliances. + +* Add thumbnail images to Appliances grid. + +* Hopefully, let the Grid class generate a default list of columns. + +* Don't include grid filters for LargeBinary columns. + + +0.7.42 (2018-10-18) +------------------- + +* Fix a dialog button for Chrome. + + +0.7.41 (2018-10-17) +------------------- + +* Cache user permissions upon "new request" event. + +* Add basic Excel download support for Products table. + + +0.7.40 (2018-10-13) +------------------- + +* Add "hours as decimal" hover text for some HH:MM timesheet values. + + +0.7.39 (2018-10-09) +------------------- + +* Fix bug when non-numeric entry given for mobile inventory "quick row". + +* Show tempmon readings when viewing client or probe. + +* Auto-disable button when sending email preview. + +* Add some helptext for various tempmon fields. + +* Allow override of jquery for base templates, desktop and mobile. + +* Improve "length" (hours) column for Worked Shifts grid. + +* Add basic Excel download support for raw worked shifts. + + +0.7.38 (2018-10-03) +------------------- + +* Add support for "archived" flag in Tempmon Client views. + +* Expose notes field for tempmon client and probe views. + +* Expose new ``disk_type`` field for tempmon client views. + +* Tweak how receiving rows are looked up when adding to the batch. + + +0.7.37 (2018-09-27) +------------------- + +* Restrict (temporarily I hope) webhelpers2_grid to 0.1. + + +0.7.36 (2018-09-26) +------------------- + +* Leverage alternate code also, for mobile product quick lookup. + +* Misc. UI improvements for truck dump receiving on desktop. + +* Add speedbump by default when deleting any "row" record. + +* Expose ``item_entry`` field for receiving batch row. + +* Capture user input for mobile receiving, and move some lookup logic. + + +0.7.35 (2018-09-20) +------------------- + +* Fix batch row status breakdown, for rows with no status. + + +0.7.34 (2018-09-20) +------------------- + +* Add unique check for "name" when creating new Role. + +* Fix bug when editing truck dump child batch row quantities. + +* Add setting to show/hide product image for mobile purchasing/receiving. + +* Show red background for mobile receiving if product not found. + +* Add quick-receive 1EA, 3EA, 6EA for mobile receiving. + +* Fix how we check config for mobile "quick receive" feature. + +* Do quick lookup by vendor item code, alt code for mobile receiving. + +* Fix price fields, add pref. vendor/cost fields for mobile product view. + +* Add simple row status breakdown when viewing batch. + +* Only show mobile "quick receive" buttons if product is identifiable. + + +0.7.33 (2018-09-10) +------------------- + +* Fix default (status) filter for Employees grid. + + +0.7.32 (2018-08-24) +------------------- + +* Add "quick receive all" support for mobile receiving. + +* Refactor sqlerror tween to add support for pyramid_retry. + +* Honor view logic when displaying Delete Row button for mobile receiving. + + +0.7.31 (2018-08-14) +------------------- + +* Make sure we refresh batch status when adding a new row. + +* Hide 'ordered' columns for truck dump parent row grid. + +* Add support for editing "claim" quantities for truck dump child row. + +* Use invoice total, PO total as fallback, for mobile receiving list. + +* Show links to claiming rows for truck dump parent row. + +* Add "quick lookup" for mobile Products page. + + +0.7.30 (2018-07-31) +------------------- + +* Don't configure versioning when making the app. + + +0.7.29 (2018-07-30) +------------------- + +* Various tweaks for arbitrary model view with "rows". + + +0.7.28 (2018-07-26) +------------------- + +* Let mobile form declare if/how to auto-focus a field. + +* Assign purchase to new receiving batch via uuid instead of object ref. + +* Fix permission group label for Ordering Batches. + +* Redirect to "view parent" after deleting a row. + + +0.7.27 (2018-07-19) +------------------- + +* Use upload time as default filter/sort for Trainwreck transactions. + +* Add initial support for mobile "quick row" feature, for ordering. + +* Add product grid filters for "on hand", "on order". + +* Don't make customer ID readonly when editing. + +* Fix Person.customers readonly field for python 3. + +* Traverse master class hierarchy to collect all defined labels. + +* Add 'person' column for customers grid. + +* Fix how we check file size when reading stdout for upgrade. + +* Add runtime ``mobile`` flag for ``MasterView``. + +* Improve basic mobile views for customers, people. + +* Refactor mobile receiving to use "quick row" feature. + +* Improve support for "receive from scratch" workflow, esp. for mobile. + +* Add (admin-friendly!) view to manage some App Settings. + +* Add (restore?) basic support for mobile receiving from PO. + +* Expose status etc. when editing upgrade; rename Email Settings. + + +0.7.26 (2018-07-11) +------------------- + +* Force user to count "units" and not "packs" for inventory batch. + +* Fix bug for inventory batch when product not found. + +* Sort mobile receiving rows by last modified instead of sequence. + +* Tweak default page title for master view. + +* Show "truck dump" info for applicable receiving batch page title. + +* Highlight purchasing batch rows with "case quantity differs" status. + +* Improve how cases/units, uom are handled for mobile receiving. + +* Add "?" for daily time sheet total if partial shift present. + +* Fix cancel button for progress page. + + +0.7.25 (2018-07-09) +------------------- + +* Fix enum values for customer email preference grid filter. + +* Tweak field ordering for customer form. + +* Remove deprecated "edbob" settings. + +* Improve basic support for unit/pack info when viewing product details. + + +0.7.24 (2018-07-03) +------------------- + +* Tweak how some "pack item" fields are displayed when viewing product. + + +0.7.23 (2018-07-03) +------------------- + +* Don't read upgrade progress file if size hasn't changed. + +* Fix batch file download link URL. + +* Fix batch action kwargs, so 'action' can be a handler kwarg. + + +0.7.22 (2018-06-29) +------------------- + +* Consider any integer greater than PG allows, to be invalid grid filter value. + + +0.7.21 (2018-06-28) +------------------- + +* Fix bug when populating new batch. + +* Allow zero quantity for inventory batch rows. + +* Allow editing of unit cost for inventory batch row. + +* Add overflow validation for cases/units in inventory batch desktop form. + +* Add ``credit_total`` column for purchase credits grid. + +* Don't aggregate product for mobile truck dump receiving. + +* Be smarter about when we sort receiving batch by most recent (for mobile). + +* Accept invoice number when adding truck dump child from invoice file. + +* Add highlight for "cost not found" rows in purchasing batch. + +* Fix email preview logic per python 3. + +* Improve basic support for adding new product. + +* Show department column for receiving batch rows. + +* Fix how "unknown product" row is added to receiving batch. + + +0.7.20 (2018-06-27) +------------------- + +* Fix input validation for integer grid filter. + + +0.7.19 (2018-06-14) +------------------- + +* Change how date fields are handled within grid filters. + +* Add workaround for using pip 10.0 "internal" API in upgrades view. + + +0.7.18 (2018-06-14) +------------------- + +* Auto-size columns for Excel results download. + +* Add Excel results download for categories, report codes. + +* Use "known" label if possible when making new grid filters. + +* Expose new ``exempt_from_gross_sales`` flags. + + +0.7.17 (2018-06-09) +------------------- + +* Allow products view to set some labels in costs grid. + +* Let config override ``sys.prefix`` when launching batch commands in subprocess. + + +0.7.16 (2018-06-07) +------------------- + +* Add versioning workaround support for batch actions. + + +0.7.15 (2018-06-05) +------------------- + +* Add integer-specific grid filter. + +* Set filter value renderer when setting enum for grid field. + + +0.7.14 (2018-06-04) +------------------- + +* Show department instead of subdept by default, for products grid. + +* Add support for variance inventory batches, aggregation by product. + +* Set default column renderers for grid based on data types. + +* Expose 'hidden' flag for inventory adjustment reasons. + +* Expose new ``Vendor.abbreviation`` field. + + +0.7.13 (2018-05-31) +------------------- + +* Show 'variance' field when viewing inventory batch row. + + +0.7.12 (2018-05-30) +------------------- + +* Make sure count mode is preserved when making new inventory batch. + +* Add initial support for "variance" inventory batch mode. + +* Fix handling of (missing) password when user is edited. + + +0.7.11 (2018-05-25) +------------------- + +* Add ``Form.__contains__()`` method. + +* Improve default behavior for receiving a purchase batch. + +* Fix label profile type field when editing label batch row. + +* Allow lookup of inventory item by alternate code. + +* Fix rowcount bug when first row added via ordering worksheet. + +* Add "most of" support for truck dump receiving. + +* Add docs for ``MasterView.help_url`` and ``get_help_url()``. + +* Add "Receive 1 CS" button for better efficiency in mobile receiving. + +* Add category name filter for products grid. + +* Increase allowed width for form labels. + +* Add ``allow_zero_all`` flag for inventory batch master. + +* Add buttons to toggle batch 'complete' flag when viewing batch. + +* Hide "create new row" link for batches which are marked complete. + +* Add way to prevent "case" entries for inventory adjustment batch. + +* Add ``MasterView.use_byte_string_filters`` flag for encoding search values. + + +0.7.10 (2018-05-02) +------------------- + +* Add sort/filter for department name, for Categories grid. + + +0.7.9 (2018-04-12) +------------------ + +* Add future mode for vendor catalog batch. + + +0.7.8 (2018-04-09) +------------------ + +* Add awareness for ``Email.dynamic_to`` flag in config UI. + +* Add new vendor catalog row status, render product with hyperlink. + + +0.7.7 (2018-03-23) +------------------ + +* Use 'today' as fallback order date for ordering worksheet. + +* Treat unknown UPC as "product not found" for inventory batch. + +* Refactor inventory batch desktop lookup, to allow for Type 2 UPC logic. + +* Fix default selection bug for store/department time sheet filters. + + +0.7.6 (2018-03-15) +------------------ + +* Fix text area behavior for email recipient fields. + +* Fix autodisable button bug for forms marked as such. + + +0.7.5 (2018-03-12) +------------------ + +* Add desktop support for creating inventory batches. + +* Expose vendor item code for purchase credits. + +* Fix default create logic for vendors, products. + +* Add changelog link for rattail-tempmon in upgrade diff. + +* Add ``disable_submit_button()`` global JS function. + +* Add basic support for making new product on-the-fly during mobile ordering. + + +0.7.4 (2018-02-27) +------------------ + +* Use all "normal" product form fields, for mobile view. + +* Refactor ordering worksheet to use shared logic. + +* Add download path for batch master views. + +* Add basic mobile support for executing batches (with options). + +* Add ``NumberInputWidget`` for ``<input type="number" />``. + +* Add ``Form.mobile`` flag and set link button styles accordingly. + +* Always show flash-error-style message when form has errors. + +* Use ``Form.submit_label`` if present, or fall back to ``save_label``. + +* Expose ``ship_method`` and ``notes_to_vendor`` for purchase, ordering batch. + +* Bind batch to its execution options schema, when applicable. + +* Don't set order date for new ordering batch when created via mobile. + +* Don't allow row deletion if batch is marked complete. + +* Add logic for editing default phone/email in base master view. + +* Fix bug in users view when person field not present. + + +0.7.3 (2018-02-15) +------------------ + +* More tweaks for python 3. + + +0.7.2 (2018-02-14) +------------------ + +* Refactor all remaining forms to use colander/deform. + +* Coalesce 'forms2' => 'forms' package. + +* Remove dependencies: FormAlchemy, FormEncode, pyramid_simpleform, pyramid_debugtoolbar + +* Misc. cleanup for Python 3. + +* Add generic 'login_as_home' setting. + +* Add tailbone version to base stylesheet URLs. + + +0.7.1 (2018-02-10) +------------------ + +* Make it easier to hide buttons for a form. + +* Let forms choose *not* to auto-disable their cancel button. + +* Add 'newstyle' behavior for ``Form.validate()``. + +* Add some basic ORM object field types for new forms. + +* Make sure each grid has unique set of actions. + +* Add 'gridcore' jQuery plugin, for core behavior. + +* Allow passing arbitrary attrs when rendering grid. + +* Refactor mobile receiving to use colander/deform. + +* Refactor mobile inventory to use colander/deform. + +* Refactor user login, change password to use colander/deform. + +* Fix some bugs with importer batch views. + + +0.7.0 (2018-02-07) +------------------ + +* Coalesce all master views back to single base class. + +* Add ``append()`` and ``replace()`` methods for core Grid class. + +* Show year dropdown by default for jQuery UI date pickers. + +* Don't process file for new batch unless field is present. + +* Add setting for "force home" mobile behavior. + +* Add 'plain' and 'jquery' templates for deform select widget. + +* Add "hidden" concept for form fields. + +* Add ``Form.show_cancel`` flag, for hiding that button. + +* Let each form define its "save" button text. + +* Add master view for ``EmailAttempt``. + +* Avoid "auto disable" button logic for new message form. + +* Add better UPC validation for mobile receiving. + + +0.6.69 (2018-02-01) +------------------- + +* Add proper enum for inventory batch "count mode" filter. + +* Fix bugs when making inventory batch on mobile. + + +0.6.68 (2018-01-31) +------------------- + +* Cap zope.sqlalchemy dependency at pre-1.0. + + +0.6.67 (2018-01-30) +------------------- + +* Fix permission bug when adding row in mobile receiving. + +* Fix mobile logout behavior. + +* Always redirect to mobile home page, if "other" page is refreshed. + + +0.6.66 (2018-01-29) +------------------- + +* Add support for detaching Person from Customer. + +* Allow disabling auto-dismiss of flash messages on mobile. + +* Add ``FieldList`` wrapper for grid columns list. + +* Show "unit cost" column by default, for products grid. + +* Improve case/unit quantity validation for order worksheet. + +* Show new 'confirmed' field for brands table. + +* Add support for extra column(s) in timesheet view table. + +* Add generic "download results as XLSX" feature. + +* Add vendor links in cost grid when viewing product. + +* Show "buttons" when viewing an object, with forms2 (i.e. Execute Batch). + +* Refactor "most" remaining batch views etc. to use master3. + + +0.6.65 (2018-01-24) +------------------- + +* Fix some master3 edit issues for products view. + +* Let custom inventory batch view override logic for mobile UPC scanning. + +* Show new ``cashback`` field for Trainwreck transaction. + +* Add 'delete-instance' class to delete link when viewing a record. + + +0.6.64 (2018-01-22) +------------------- + +* Warn if user "scans" UPC with more than 14 digits, for mobile inventory. + +* Add option for preventing new inventory batch rows for unknown products. + +* Add ``creates_multiple`` flag for master view. + +* Add basic support for per-page help URL. + + +0.6.63 (2018-01-16) +------------------- + +* Fix bug when locating association proxy column. + +* Fix client field when creating / editing tempmon probe. + +* Allow editing of inventory batch count mode and reason code. + + +0.6.62 (2018-01-11) +------------------- + +* Fix dialog button click event when executing price batch (for Chrome). + +* Fix some mobile view URLs. + +* Show case quantity for inventory batch rows. + +* Let custom schema node start out with empty children. + +* Allow passing None to ``Form.set_renderer()``. + + +0.6.61 (2018-01-11) +------------------- + +* Provide some default readonly form field renderers. + +* Fix row query bug when deleting batch row. + + +0.6.60 (2018-01-10) +------------------- + +* Refactor several straggler views to use master3. + +* Add first attempt at master3 for batch views. + + +0.6.59 (2018-01-08) +------------------- + +* Fix bug when printing product label. + + +0.6.58 (2018-01-08) +------------------- + +* Tweak diff styles when viewing upgrade. + + +0.6.57 (2018-01-07) +------------------- + +* Fix some styles for execution options dialog. + +* Show 'static_prices' flag for label batches. + +* Add field name as wrapper class name. + +* Change how select menus are enhanced for batch exec options. + +* Add view for InventoryAdjustmentReason model. + +* Stop setting execution details when multiple batches executed. + +* Add empty default when displaying values in grid. + +* Let grids be paginated even when they have no model class. + +* Exclude JS for refreshing batch unless it's relevant. + +* Tweak conditions for CSV row download link. + +* Add basic support for row grid view links. + +* Refactor away the ``row_route_prefix`` concept. + +* Add ``row_title`` to template context for ``view_row``. + +* Tweak ``diffs.css`` and refactor 'view_version' template to use it. + +* Add basic UI support for "importer batch" feature. + + +0.6.56 (2018-01-05) +------------------- + +* Fix bug when making batch from product query. + + +0.6.55 (2018-01-04) +------------------- + +* Add "price required" flag to product view. + +* Add a bit more flexibility to jquery time input values. + +* Show row count field when viewing vendor catalog batch. + +* Tweak product filter for report code name. + +* Refactor forms logic when making batch from product query. + + +0.6.54 (2017-12-20) +------------------- + +* Provide sane width for filter value dropdowns. + + +0.6.53 (2017-12-19) +------------------- + +* Accept ``value_enum`` kwarg when creating grid filter. + + +0.6.52 (2017-12-08) +------------------- + +* Add transaction "System ID" field for Trainwreck. + +* Add ``Grid.set_sort_defaults()`` method. + +* Change template prefix for vendor catalog batches. + +* Add basic "helptext" support for forms2. + +* Add cleared/selected callbacks for jquery autocomplete in forms2. + +* Add ``Grid.remove_filter()`` method. + +* Add custom schema type for jQuery time picker data. + +* Refactor lots of views to use master3. + + +0.6.51 (2017-12-03) +------------------- + +* Refactor customers view to use master3. + +* Add custom ``FieldList`` class for forms2 field list. + +* Auto-scroll window as needed to ensure drop-down choices are visible. + +* Hide status when creating new purchasing batch. + +* Add "manually priced" awareness to pricing batch UI. + +* Add batch description to page body title. + +* Fix batch row count when bulk-deleting rows. + +* Allow bulk delete of label batch rows. + +* Expose description and notes for label batches. + +* Let batch views allow or deny "execute results" option. + +* Allow "execute results" for inventory batches. + +* Fix permission bug for mobile inventory batch. + +* Expose default address for customers view. + + +0.6.50 (2017-11-21) +------------------- + +* Set widget when defining enum for a form2 field. + +* Add date/time-picker, autocomplete support for forms2 (deform). + +* Add colander magic for association proxy fields. + + +0.6.49 (2017-11-19) +------------------- + +* Improve auto-disable logic for some form buttons. + +* Fix (hack) for editing some department flags. + + +0.6.48 (2017-11-11) +------------------- + +* Accept ``None`` as valid arg for ``Grid.set_filter()``. + + +0.6.47 (2017-11-08) +------------------- + +* Fix manifest to include ``*.pt`` deform templates + + +0.6.46 (2017-11-08) +------------------- + +* Add ``json`` to global template context + + +0.6.45 (2017-11-01) +------------------- + +* Add product and personnel flags for Department + +* Add sorters, filters for Product regular, current price + +* Add "text" type for new form fields + +* Add description, notes for pricing batches + + +0.6.44 (2017-10-29) +------------------- + +* Fix join bug for Upgrades table when sorting by executor + + +0.6.43 (2017-10-29) +------------------- + +* Add "make user" button when viewing person w/ no user account + + +0.6.42 (2017-10-28) +------------------- + +* Add cashier info, upload time for Trainwreck transaction views + + +0.6.41 (2017-10-25) +------------------- + +* Add support for validator and required flag, for new forms + +* Use master3 view for datasync changes + + +0.6.40 (2017-10-24) +------------------- + +* Add grid filter which treats empty string as NULL + +* Fix value auto-selection for enum grid filters + +* Add ``item_id`` to trainwreck views + +* Expose ``Person.users`` relationship (readonly) + + +0.6.39 (2017-10-20) +------------------- + +* Fix bug with products view config + + +0.6.38 (2017-10-19) +------------------- + +* Add "local" datetime renderer for new grids, forms + +* Make CSRF protection optional (but on by default) + +* Convert user feedback mechanism to use modal dialog + +* Add 'active' column to Users table view + +* Add "download row results as CSV" feature to master view + +* Add support for setting default field values on new forms + +* Add 'currency' field type for new forms + +* Allow passing ``None`` to ``Grid.set_joiner()`` + + +0.6.37 (2017-09-28) +------------------- + +* Fix data type/size issue with CSV download + +* Don't set batch input file on creation, if no file exists + +* Add "auto-enhance" select field template for deform + +* Add ability to override schema node for custom deform fields + +* Fix deform widget resource inclusion for master/create template + +* Pass form along to ``before_create_flush()`` in master3 + +* Add "populatable" for master views (populating new objects with progress) + +* Add 'duration' type for new form fields + + +0.6.36 (2017-09-15) +------------------- + +* Fix user field rendering when no person associated + +* Add generic support for downloading list results as CSV + +* Tweak title for master view row template + + +0.6.35 (2017-08-30) +------------------- + +* Fix some bugs for rendering upgrade package diffs + + +0.6.34 (2017-08-18) +------------------- + +* Fix mobile inventory template + +* Add extra perms for creating inventory batch w/ different modes + +* Allow batch execution to require options on a per-batch basis + +* Convert more views to master3: + departments, subdepartments, categories, brands, bouncer, customer groups + +* Override deform template for checkbox field; fix label behavior + +* Show all grid actions by default, if there are 3 or less + +* Use shared logic for executing upgrade + + +0.6.33 (2017-08-16) +------------------- + +* Add ``LocalDateTimeFieldRenderer`` for formalchemy + +* Fix auto-disable button on form submit, per Chrome issues + + +0.6.32 (2017-08-15) +------------------- + +* Add generic changelog link for rattail/tailbone packages + +* Let handler delete files when deleting upgrade + +* Add mechanism for user to bulk-change status for purchase credits + +* Tweak how pyramid config is created during app startup, for tests + +* Fix permission used for mobile receiving item lookup + + +0.6.31 (2017-08-13) +------------------- + +* Add show all vs. show diffs for upgrade packages + +* Add initial support for changelog links for upgrade package diffs + +* Add prev/next buttons when viewing upgrade details + +* Merge 'better' theme into base templates + + +0.6.30 (2017-08-12) +------------------- + +* Make product field renderer allow override of link text rendering + + +0.6.29 (2017-08-11) +------------------- + +* Various tweaks to inventory batch logic (zero-all mode etc.) + +* Fix join bug for users grid + +* Flush session once every 1000 records when bulk-deleting + + +0.6.28 (2017-08-09) +------------------- + +* Fix clone config bug for label batches + + +0.6.27 (2017-08-09) +------------------- + +* Improve inventory support, plus "hiding" person data while still using it + +* Fix encoding bug when reading stdout during upgrade + + +0.6.26 (2017-08-09) +------------------- + +* Add awareness of upgrade exit code, success/fail + +* Add support for cloning an upgrade record + +* Add running display of stdout.log when executing upgrade + + +0.6.25 (2017-08-08) +------------------- + +* Specify ``expire_on_commit`` for tailbone db session + + +0.6.24 (2017-08-08) +------------------- + +* Fix bug which caused new empty worked shift when editing time sheet + + +0.6.23 (2017-08-08) +------------------- + +* Fix bulk-delete for batch rows, allow it for pricing batches + +* Fix permission check for deleting single batch rows + +* Fix numeric filter to allow 3 decimal places by default + + +0.6.22 (2017-08-08) +------------------- + +* Remove unwanted import (which broke versioning) + +* Add some links to employees grid + + +0.6.21 (2017-08-08) +------------------- + +* Refactor progress bars somewhat to allow file-based sessions + +* Fix recipients renderer for email settings grid + +* Improve status tracking for upgrades; add package version diff + + +0.6.20 (2017-08-07) +------------------- + +* Record become/stop root user events + +* Make datasync changes bulk-deletable + +* Add basic support for performing / tracking app upgrades + + +0.6.19 (2017-08-04) +------------------- + +* Record basic user login/logout events + +* Expose UserEvent table in UI + + +0.6.18 (2017-08-04) +------------------- + +* Add progress support for bulk deletion + +* Make tempmon readings bulk-deletable + + +0.6.17 (2017-08-04) +------------------- + +* Various view tweaks + + +0.6.16 (2017-08-04) +------------------- + +* Add auto-links for most grids + +* Fix row highlighting for sources panel on product view + + +0.6.15 (2017-08-03) +------------------- + +* Allow product field renderer to suppress hyperlink + +* Add 'data-uuid' attr for mobile grid list items, if applicable + +* Initial (partial) support for mobile ordering + +* Some tweaks to ordering batch views + +* Fix bug when request.user becomes unattached from session (?) + +* Add view for consuming new batch ID + +* Add some links to various grid columns + +* Fix bug in master view_row + + +0.6.14 (2017-08-01) +------------------- + +* Make login template use same logo as home page + +* Fix how we detect grid settings presence in user session + +* Improve verbiage for exception view + +* Fix styles for message compose template + +* Various improvements to batch worksheets, index links etc. + +* Fix batch links when viewing purchase object + +* Add "on order" count to products grid, tweak product notes panel + + +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 + +* Stop allowing pre-0.7 SQLAlchemy + + +0.6.11 (2017-07-18) +------------------- + +* Tweak some basic styles for forms/grids + +* Add new v3 master with v2 forms, with colander/deform + + +0.6.10 (2017-07-18) +------------------- + +* Fix grid bug if "current page" becomes invalid + + +0.6.9 (2017-07-15) +------------------ + +* Expose version history for all supported tables + + +0.6.8 (2017-07-14) +------------------ + +* Provide default renderers for SA mapped tables, where possible + +* Add flexible grid class for v3 grids for width=half etc. + +* Final grid refactor; we now have just 'grids' :) + +* Refactor (coalesce) all batch-related templates + + +0.6.7 (2017-07-14) +------------------ + +* Fix master view ``get_effective_data()`` for v3 grids + + +0.6.6 (2017-07-14) +------------------ + +* Fix bug for printing one-off product labels + + +0.6.5 (2017-07-14) +------------------ + +* Fix template/styles for v3 grid views, add purchasing batch status + + +0.6.4 (2017-07-14) +------------------ + +* Add new "v3" grids, refactor all views to use them + + +0.6.3 (2017-07-13) +------------------ + +* Sort mobile receiving batches by ID desc + +* Add initial/basic support for "simple" mobile grid filter w/ radio buttons + +* Add filter support for mobile row grid; plus mark receiving as complete + +* Disable unused Clear button for mobile receiving + +* Add logic for mobile receiving if product not in batch and/or system + +* Prevent mobile receiving actions for batch which is complete or executed + +* Fix bug with mobile receiving UPC lookup; require stronger "create row" perm + +* Stop using popup for expiration date, for mobile receiving + +* Add global key handler for mobile receiving, for scanner wedge input + +* Make all batches support mobile by default + +* Add basic support for viewing inventory batches on mobile + +* Refactor keypad widget for mobile receiving + +* Add unit cost for inventory batches + + +0.6.2 (2017-07-10) +------------------ + +* Fix CS/EA bug for mobile receiving + + +0.6.1 (2017-07-07) +------------------ + +* Switch license to GPL v3 (no longer Affero) + +* Fix broken product image tag, per webhelpers2 + + +0.6.0 (2017-07-06) +------------------ + +Main reason for bumping version is the (re-)addition of data versioning support +using SQLAlchemy-Continuum. This feature has been a long time coming and while +not yet fully implemented, we have a significant head start. + +* Add custom default grid row size for Trainwreck items + +* Make hyperlink optional for employee field renderer + +* Tweak how customer/person relationships are displayed + +* Add initial support for expiration date for mobile receiving + +* Make Person.employee field readonly + +* Rearrange some imports to ensure ``rattail.db.model`` comes last + +* Add basic versioning history support for master view + +* Remove old-style continuum version views + +* Remove all "old-style" (aka. version 1) grids + +* Remove all old-style views: grids, CRUD, versions etc. + +* Refactor to use webhelpers2 etc. instead of older 'webhelpers' + + +0.5.104 (2017-06-22) +-------------------- + +* Add basic views for Trainwreck transactions + +* Add ``AlchemyLocalDateTimeFilter`` + +* Add row count as available column to batch header grids + +* Try to keep batch status updated; display it for handheld batches + +* Tweak display of inventory/label batches to reflect multiple handheld batches + +* Add way to execute multiple handheld batches (search results) at once + +* Fix batch row count when deleting a row + +* Make case/unit quantities prettier within Inventory batch rows grid + +* Sort (alphabetically) device type list field when making new handheld batch + +* Allow bulk row deletion for vendor catalog batches + + +0.5.103 (2017-06-05) +-------------------- + +* Always add key as class to grid column headers; allow literal label + + +0.5.102 (2017-05-30) +-------------------- + +* Remove all views etc. for old-style batches + +* Fix bug when updating Order Form data, if row.po_total is None + + +0.5.101 (2017-05-25) +-------------------- + +* Fix subtle bug when identifying purchase batch row on order form update + +* Remove references to deprecated batch handler methods + +* Add validation for unique name when creating new Setting + +* Simplify page title display for mobile base template + +* Refactor "purchasing" batch views, split off "ordering" + +* Add initial (full-ish) support for mobile receiving views + +* Add support for bulk-delete of Pricing Batches + +* Pad session timeout warning by 10 seconds, to account for drift + +* Add highlight to active row within Order Form view + +* Make 'notes' field use textarea renderer by default, for all batches + +* Add basic ability to download Ordering Batch as Excel spreadsheet + + +0.5.100 (2017-05-18) +-------------------- + +* Allow batch view to override execution failure message + +* Tweak some customer view/field rendering, to allow more customization + +* Remove customer view template (use master default) + +* Add basic support for Trainwreck database connectivity + +* Remove unused 'fake_error' view + +* Add basic 'robots.txt' support to CommonView + +* Cap our pyramid_tm version until we can upgrade to pyramid 1.9 + +* Add daily hour totals when viewing or editing single employee time sheet + +* Let config cause time sheet hours to display as HH.HH for some users + +* Expose full-time flag and start date for employee view + +* Add convenience ``dialog_button()`` JS function + + +0.5.99 (2017-05-05) +------------------- + +* Add allowance for Escape key, in numeric.js + +* Let a batch disallow bulk-deletion of its rows + +* Add basic support for deletion speedbump for row data + +* Remove lower version for Pyramid dependency, but restrict to pre-1.9 + + +0.5.98 (2017-04-18) +------------------- + +* Auto-save time sheet day editor on Enter press if time field is focused + +* Add simple flag to prevent multiple submits for Order Form AJAX + + +0.5.97 (2017-04-04) +------------------- + +* Fix signature for ``MasterView.get_index_url()`` + + +0.5.96 (2017-04-04) +------------------- + +* Tweak logic for registering exception view, to avoid test breakage + +* Add basic paging grid/index support for mobile + +* Tweak field label styles for mobile + +* Allow config to define home page image URL + + +0.5.95 (2017-03-29) +------------------- + +* Tweak organization panel for product view template + +* Add logic to core View class, to force logout if user becomes inactive + +* Detect "backwards" shift when time sheet is edited, alert user + +* Add default view for unhandled exceptions, configure only for production + +* Add basic table listing view, with rough estimate row counts + +* Add 'status' column to vendor cost table in product view + +* Various template standardization tweaks + + +0.5.94 (2017-03-25) +------------------- + +* Add ``CostFieldRenderer`` and tweak product view template + +* Bump margin between grid and header table, i.e. buttons + +* Broad refactor to improve customization of purchase order form etc. + +* Fix route sequence for people autocomplete + +* Fix bugs when checking for 'chuck' in demo mode + +* Add unit item and pack size fields to product view + + +0.5.93 (2017-03-22) +------------------- + +* Add 'is_any' verb to integer grid filters + +* Add more variations of project name when creating via scaffold + +* Various tweaks to the customer and person views/forms + +* Add basic "mobile index" master view, plus support for demo mode + +* Refactor the batch file field renderer somewhat + +* Move ``notfound()`` method to core ``View`` class + +* Add ``BatchMasterView.add_file_field()`` convenience method + +* Add ``extra_main_fields()`` method to product view template + +* Allow config to override jQuery UI version + +* Add master view for Report Output data model + + +0.5.92 (2017-03-14) +------------------- + +* Tweak grid configuration for Employees view + +* Add trailing '?' for employee time sheet when hours are incomplete + + +0.5.91 (2017-03-03) +------------------- + +* Add 'discontinued' flag to product view + + +0.5.90 (2017-03-01) +------------------- + +* Add notes, ingredients to product view + + +0.5.89 (2017-02-24) +------------------- + +* Expose/honor per-role session timeouts + +* Fix daylight savings bug when cloning schedule from previous week + +* Expose notes field for purchasing batches + +* Add some product flags (kosher vegan etc.) to view fieldset + +* Add initial support for native product images + + +0.5.88 (2017-02-21) +------------------- + +* Fix session reference bug in schedule view + + +0.5.87 (2017-02-21) +------------------- + +* Fix bug in DateFieldRenderer when no format specified + + +0.5.86 (2017-02-21) +------------------- + +* Add initial/basic views for customer orders data + +* Be less aggressive when validating schedule edit form POST + + +0.5.85 (2017-02-19) +------------------- + +* Add generic "bulk delete" support to MasterView + +* Add beginnings of mobile receiving views + + +0.5.84 (2017-02-17) +------------------- + +* Tweak progress template to better handle reset to 0% + +* Add ability to merge 2 user accounts + +* Increase size of Roles select when editing a User + +* Add ability to filter Sent Messages by recipient name + + +0.5.83 (2017-02-16) +------------------- + +* Set form id for new purchasing batch page + +* Make sure invoice number is saved when making new purchasing batch + +* Tweak product view page styles (new grids etc.) + +* Add support for client-side session timeout warning + + +0.5.82 (2017-02-14) +------------------- + +* Collapse grid actions if there are only 2 + +* Add master view for generic exports + +* Make some product fields readonly + +* Make datasync changes viewable + +* Redirect to login page when Forbidden happens with anonymous user + +* Tweak styles for Send Message page + +* Tweak form handling for sending a new message, for more customization + +* Advance to password field when Enter pressed on username, login page + +* Add way for ``login_user()`` to set different timeout depending on nature of login + + +0.5.81 (2017-02-11) +------------------- + +* Add config for redirecting user to home page after logout + +* Refactor logic used to login a user, for easier sharing + +* Use ``pretty_hours()`` function where applicable + + +0.5.80 (2017-02-10) +------------------- + +* Tweak renderer for Amount field for DepositLink view + +* Tweak how regular/current price fields are handled for Product view + +* Fix bug in base 'shifts' template if ``weekdays`` not in context + + +0.5.79 (2017-02-09) +------------------- + +* Tweak product view template per rename of case_size field + +* Refactor the Edit Time Sheet view for "autocommit" mode + +* Don't render user field as hyperlink unless so configured + +* Expose 'delay' field in tempmon client views + +* Fix bug when first entry is empty for product on ordering form + + +0.5.78 (2017-02-08) +------------------- + +* Add initial Find Roles/Users by Permission feature + +* Fix sorting bug for Employee Time Sheet view + + +0.5.77 (2017-02-04) +------------------- + +* Invoke timepicker to correct format of user input, for edit schedule/timesheet + + +0.5.76 (2017-02-04) +------------------- + +* Add hyperlink to ``EmployeeFieldRenderer`` + +* Improve the grid for ``WorkedShift`` model a bit + +* Add config flag for disabling option to "Clear Schedule" + + +0.5.75 (2017-02-03) +------------------- + +* Fix probe filter for tempmon readings grid + +* Be explicit about fieldset for pricing batch rows + +* Let project override user authentication for login page + +* Add basic support for per-user session timeout + + +0.5.74 (2017-01-31) +------------------- + +* Refactor schedule / timesheet views for better separation of concerns + + +0.5.73 (2017-01-30) +------------------- + +* Add pyramid_mako dependency, remove minimum version for rattail + +* Add ability to edit employee time sheet + +* Add 'target' kwarg for grid action links + +* Add hyperlink to User field renderer + +* Add min diff threshold param when making price batch from product query + +* Add way for batch views to hide rows with given status code(s) + + +0.5.72 (2017-01-29) +------------------- + +* Add basic support for cloning batches + +* Tweaks to order form template etc., for purchasing batch + +* Let master view with rows prevent sort/filter for row grid + +* Add price diff column to pricing batch row grid + +* Add warning highlight for pricing batch row if can't calculate price + + +0.5.71 (2017-01-24) +------------------- + +* Improve columns, filters for TempMon Readings grid + +* Add ability to merge subdepartments + + +0.5.70 (2017-01-11) +------------------- + +* Fix CSRF token bug with email preview form, refactor to use webhelpers + + +0.5.69 (2017-01-06) +------------------- + +* When making batch from products, build query *before* starting thread + + +0.5.68 (2017-01-03) +------------------- + +* Prefer received quantities over ordered quantities, for Order Form history + + +0.5.67 (2017-01-03) +------------------- + +* Add department UUID to JSON returned for "eligible purchases" when creating batch + +* Set "order date" when creating new receiving batch + +* Add "discarded" flag when receiving DMG/EXP products; add view for purchase credits + +* Fix type error in grid numeric filter + + +0.5.66 (2016-12-30) +------------------- + +* Tweak the "create" screen for purchase batches, for more customization + + +0.5.65 (2016-12-29) +------------------- + +* Fix purchase batch execution, to redirect to Purchase *or* Batch + +* Add extra perms for restricing which 'mode' of purchase batch user can create + +* Refactor Order Form a bit to allow custom history data + + +0.5.64 (2016-12-28) +------------------- + +* Tweak default "numeric" grid filter, to ignore UPC-like values + +* Tweak default filter label for Batch ID + + +0.5.63 (2016-12-28) +------------------- + +* Fix CSRF token bug for bulk-move message forms + + +0.5.62 (2016-12-22) +------------------- + +* Fix CSRF token bug for old-style batch params form + + +0.5.61 (2016-12-21) +------------------- + +* Fix master merge template/forms to include CSRF token + + +0.5.60 (2016-12-20) +------------------- + +* Fix CSRF bug in Ordering Form template, make case quantity pretty + +* Fix some bugs in product view template + +* Update some enum references, render all purchase/batch cases/units fields as quantity + + +0.5.59 (2016-12-19) +------------------- + +* Add ``QuantityFieldRenderer`` + +* Add style for 'half-width' grid + + +0.5.58 (2016-12-16) +------------------- + +* Add ``ValidGPC`` formencode validator + +* Overhaul the Receiving Form to account for "product not found" etc. + +* Auto-append slash to URL when necessary + +* Add "print receiving worksheet" feature, for 'ordered' purchases + +* Add global CSRF protection + +* Tweak some field renderers + +* Overhaul product views a little, per customization needs + + +0.5.57 (2016-12-12) +------------------- + +* Lots of changes for sake of mobile login / user menu etc. + +* Add mobile support for datasync restart + +* Make ``CurrencyFieldRenderer`` inherit from ``FloatFieldRenderer`` + +* Fix session bug in old CRUD views + + +0.5.56 (2016-12-11) +------------------- + +* Show 'enabled' column in grid, fix prefix bug for email profiles + +* Tweak flash message when sending email preview, in case it's disabled + +* Hide first/last name for employee view, unless in readonly mode + +* Add initial mobile templates: base, home, about + + +0.5.55 (2016-12-10) +------------------- + +* Validate for unique tempmon probe config key + +* Add 'restartable tempmon client' conditional logic + + +0.5.54 (2016-12-10) +------------------- + +* Add new 'receiving form' for purchase batches + +* Add support for 'department' field in purchases / batches + +* Add generic 'not on file' product image for use as POD 404 + +* Add logic for handling Ctrl+V / Ctrl+X in numeric.js + + +0.5.53 (2016-12-09) +------------------- + +* Fix bug when editing a data row + + +0.5.52 (2016-12-08) +------------------- + +* Fix permission group label for email bounces + +* Update footer text/link per new about page + + +0.5.51 (2016-12-07) +------------------- + +* Fix permission / grid action bug for email profiles + + +0.5.50 (2016-12-07) +------------------- + +* Tweak tempmon views a little, fix client restart logic + +* Add 'extra_styles' to true base template + +* Add new "bytestring" filter for grids that need it + + +0.5.49 (2016-12-05) +------------------- + +* Allow delete for datasync changes + +* Fix import bugs with tempmon views + +* Use master view's session when creating form + + +0.5.48 (2016-12-05) +------------------- + +* Tweak email config views, to support subject "templates" + +* Refactor tempmon views to leverage rattail-tempmon database + + +0.5.47 (2016-11-30) +------------------- + +* Fix bug in products view class + + +0.5.46 (2016-11-29) +------------------- + +* Add basic 'about' page with some package versions + +* Tweak fields for product view + + +0.5.45 (2016-11-28) +------------------- + +* Fix styles for 'print schedule' page + +* Add permission for bulk-delete of batch data rows + + +0.5.44 (2016-11-22) +------------------- + +* Add some links between employees / people / customers views + +* Add support for pricing batches + +* Add initial views for tempmon clients/probes/readings + + +0.5.43 (2016-11-21) +------------------- + +* Add support for receive/cost mode, purchase relation for purchase batches + +* Bump jquery version + +* Fix bug when downloading batch file + + +0.5.42 (2016-11-20) +------------------- + +* Move ``get_batch_kwargs()`` to ``BatchMasterView`` + + +0.5.41 (2016-11-20) +------------------- + +* Add printer-friendly view for "full" employee schedule + +* Fix some bugs etc. with batch views and templates + + +0.5.40 (2016-11-19) +------------------- + +* Add size, extra link fields to product view template + +* Refactor batch views / templates per rattail framework overhaul + + +0.5.39 (2016-11-14) +------------------- + +* Make POD image for product view a bit more sane + +* Disable save button when creating new object + + +0.5.38 (2016-11-11) +------------------- + +* Tweak default factory for boolean grid filters + +* Add support for more cases + units, more vendor fields, for new purchase batches + + +0.5.37 (2016-11-10) +------------------- + +* Display sequence for product alt codes + +* Change how we determine default 'grid key' for master views + +* Add 'additive fields' concept to merge diff preview + + +0.5.36 (2016-11-09) +------------------- + +* Add historical amounts to new purchase Order Form, allow extra columns etc. + +* Tweak verbiage for merge template etc. + + +0.5.35 (2016-11-08) +------------------- + +* Add support for new Purchase/Batch views, 'create row' master pattern + +* Add basic views for label batches + +* Add support for making new-style batches from products grid query + +* Add initial support for viewing new purchase batch as Order Form + +* Refactor how batch editing is done; don't include rows for that sometimes + + +0.5.34 (2016-11-02) +------------------- + +* Add basic merge feature to ``MasterView`` + + +0.5.33 (2016-10-27) +------------------- + +* Fix template bug when deleting user + +* Tweak default styles for home page + +* Show vendor invoice rows as warning, if they have no case quantity + +* Add 'vendor code' and 'vendor code (any)' filters for products grid + +* Fix bug with how we auto-filter 'deleted' products (?) + + +0.5.32 (2016-10-19) +------------------- + +* Fix / improve progress display somewhat + +* Disable "true delete" button by default, when clicked + +* Fix bug in batch ID field renderer, when displayed for new batch + +* Add ``refresh_after_create`` flag for ``BatchMasterView`` + +* Disable a focus() call in menubar.js which messed with search filter focus + +* Let any 'admin' user elevate to 'root' for full system access + +* Update references to ``request.authenticated_userid`` + + +0.5.31 (2016-10-14) +------------------- + +* Add ability to edit employee schedule + + +0.5.30 (2016-10-10) +------------------- + +* Tweak some things to make demo project more "out of the box" + +* Add registration for 'rattail' template with Pyramid scaffold system + +* Add 'tailbone' to global template context, update 'better' template footer + +* Tweak how tailbone finds rattail config from pyramid settings + +* Remove last references to 'edbob' package + +* Strip whitespace from username field when editing User + +* Fix couple of bugs for vendor catalog views + +* Add size description to inventory report + + +0.5.29 (2016-10-04) +------------------- + +* Add ``code`` field to Category views + +* Add "bulk delete rows" feature to new batches view + + +0.5.28 (2016-09-30) +------------------- + +* Add specific permissions for edit/delete of individual batch rows + + +0.5.27 (2016-09-26) +------------------- + +* Add basic form validation when sending new messages + +* Add "just in time" editable instance check for master view + +* Add "refresh" button when viewing batch + +* Add FormAlchemy-compatible validators for email address, phone number + +* Improve validation for FormAlchemy date field renderer + +* Fix row-level visibility for grid edit action + +* Add a couple of extra verbs to base grid filter class + +* Tweak how a grid filter factory is determined + + +0.5.26 (2016-09-01) +------------------- + +* Add ``MasterView.listable`` flag for disabling grid view + +* Fix permission group label bug for batch views + +* Allow opt-out for "download batch row data as CSV" feature + + +0.5.25 (2016-08-23) +------------------- + +* Tweak how we use DB session to fetch grid settings + +* Add "sub-rows" support to MasterView class + +* Refactor batch views to leverage MasterView sub-rows logic + +* Refactor batch view/edit pages to share some "execution options" logic + +* Add hook to customize timesheet shift rendering + + +0.5.24 (2016-08-17) +------------------- + +* Fix bug in handheld batch view config + + +0.5.23 (2016-08-17) +------------------- + +* Fix bug when viewing batch with no execution options + + +0.5.22 (2016-08-17) +------------------- + +* Fix bug for handheld batch device type field + + +0.5.21 (2016-08-17) +------------------- + +* Add ``MasterView.render()`` method for sake of common context/logic + +* Add "empty" option to enum field renderers, if field allows empty value + +* Add support for system-unique ID in batch views etc. + +* Fix bug when deleting certain batches + +* Fix bug in batch download URL + +* Add basic support for batch execution options + +* Add basic support for new handheld/inventory batches + + +0.5.20 (2016-08-13) +------------------- + +* Add null / not null verbs back to default boolean grid filter + + +0.5.19 (2016-08-12) +------------------- + +* Only show granted permissions when viewing role details + +* Expose 'enabled' flag for email profile/settings + +* Add permissions field when viewing user details + + +0.5.18 (2016-08-10) +------------------- + +* Add ``render_progress()`` method to core view class + +* Add hopefully generic ``FileFieldRenderer`` + + +0.5.17 (2016-08-09) +------------------- + +* Add support for 10-key hyphen/period keys for numeric input fields + + +0.5.16 (2016-08-05) +------------------- + +* Fallback to empty string for email preview recipient, if current user has no address + +* Allow negative sign, decimal point for "numeric" text fields + + +0.5.15 (2016-07-27) +------------------- + +* Add initial attempt at 'better' theme + +* Add ``CodeTextAreaFieldRenderer``, refactor label profile form to use it + + +0.5.14 (2016-07-08) +------------------- + +* Allow extra kwargs to core ``View.redirect()`` method + +* Add awareness of special 'Authenticated' role, in permissions UI etc. + +* Always strip whitespace from label profile 'spec' field input + + +0.5.13 (2016-06-10) +------------------- + +* Hopefully fix some CSS for form field values + +* Add support for viewing single employee's schedule / time sheet + + +0.5.12 (2016-05-11) +------------------- + +* Add support for "full" schedule and time sheet views. + +* Move "full name" to front of Person grid columns. + +* Add rattail config object to ``Session`` kwargs. + + +0.5.11 (2016-05-06) +------------------- + +* Refactor some common FormEncode validators, plus add some more. + +* Tweak styles for jQuery UI selectmenu dropdowns. + +* Tweak timesheet styles, to give rows alternating background color. + +* Disable autocomplete for password fields when editing user. + +* Various incomplete improvements to the timesheet/schedule views. + + +0.5.10 (2016-05-05) +------------------- + +* Refactor timesheet logic, add basic schedule view. + +* Add prev/next/jump week navigation to time sheet, schedule views. + +* Add hyperlinks to product UPC and description, within main grid. + +* Fix bug in roles view. + + +0.5.9 (2016-05-02) +------------------ + +* Remove 'create batch from results' link on products index page. + +* Fix bugs in batch grid URLs. + +* Tweak how empty hours are displayed in time sheet. + + +0.5.8 (2016-05-02) +------------------ + +* Add ``MasterView.listing`` flag, for templates' sake. + +* Overhaul newgrid template header a bit, to improve styles. + +* Move ``Person.display_name`` to top of fieldset when viewing/editing. + +* Add 'testing' image, for background / watermark. + +* Add 'index title' setting to master view. + +* Add auto-hide/show magic to message recipients field when viewing. + +* Add initial support for grid index URLs. + +* Add initial/basic user feedback form support. + +* Stop trying to use PIL when generating product image tag. + + +0.5.7 (2016-04-28) +------------------ + +* Add master views for ``ScheduledShift`` model. + +* Add initial (incomplete) Time Sheet view. + + +0.5.6 (2016-04-25) +------------------ + +* Add views for ``WorkedShift`` model. + + +0.5.5 (2016-04-24) +------------------ + +* Add workarounds for certain display bugs when rendering datetimes. + +* Make currency field renderer display negative amounts in parentheses. + +* Add commas to record/page count in grid footer. + +* Tweak styles for form field labels. + + +0.5.4 (2016-04-12) +------------------ + +* Add support for column header title (tooltip) in new grids. + +* Change default filter type for integer fields, in new grids. + +* Add flag for rendering key value, for enum field renderers. + +* Fix case-sensitivity when sorting permission group labels. + + +0.5.3 (2016-04-05) +------------------ + +* Fix redirect bug when attempting bulk row delete for nonexistent batch. + +* Add comma magic back to ``CurrencyFieldRenderer``. + +* Add the 'is any' verb to default list for most grid filters. + +* Add new ``TimeFieldRenderer``, make it default for ``Time`` fields. + +* Add last-minute check to ensure master views allows deletion. + + +0.5.2 (2016-03-11) +------------------ + +* Make ``tailbone.views.labels`` a subpackage instead of module. + +* Add 'executed' to old batches grid view. + +* Make all timestamps show "raw" by default (with "diff" tooltip). + +* Improve grid filters for datetime fields (smarter verbs). + +* Fix bug where batch creator was being set to current user anytime it was viewed..yikes. + + +0.5.1 (2016-02-27) +------------------ + +* Fix bug when rendering email bounce links. + + +0.5.0 (2016-02-15) +------------------ + +* Refactor products view(s) per new master pattern. + +* Make our ``DateTimeFieldRenderer`` the default for datetime fields. + +* Add new ``BatchMasterView`` for new-style batches. + +* Overhaul vendor catalogs, vendor invoices views to use new batch master class. + +* Refactor some more model views to use MasterView. (depositlink, tax, emailbounce) + +* Make datasync views easier to customize. + + +0.4.42 +------ + +* Add initial reply / reply-all support for messages. + +* Add subscriber hook for setting inbox count in template context. + + +0.4.41 +------ + +* Tweak how we connect a user to a batch, when refreshing. + +* Add 'Move' button to message view template. + + +0.4.40 +------ + +* Make rattail config object use our scoped session, when consulting db. + + +0.4.39 +------ + +* Add support for sending new messages. + + +0.4.38 +------ + +* Add 'password is/not null' filter to users list view. + +* Remove style hack for message grid views. + + +0.4.37 +------ + +* Add 'messages.list' permission, to protect inbox etc. + + +0.4.36 +------ + +* Fix bug when marking batch as executed. + + +0.4.35 +------ + +* Change default form buttons so Cancel is also a button. + +* Add 'Stores' and 'Departments' fields to Employee fieldset. + + +0.4.34 +------ + +* Add 'restart datasync' button to datasync changes list page. + +* Add autocomplete vendor field renderer. + +* Change vendor catalog upload, to allow vendor-less parsers. + +* Stop depending on PIL...for now? + + +0.4.33 +------ + +* Add employee/department relationships to employee and department views. + + +0.4.32 +------ + +* Add edit mode for email "profile" settings. + +* Fix auto-creation of grid sorter, when joined table is involved. + +* Add initial support for 'messages' views. + + +0.4.31 +------ + +* Add speed bump / confirmation page when deleting records. + +* Add "grid tools" to "complete" grid template. + +* Add ``Person.middle_name`` to the fieldset. + + +0.4.30 +------ + +* Add config extension, to record data changes if so configured. + +* Add mailing address to person fieldset. + + +0.4.29 +------ + +* Fix some route names. + + +0.4.28 +------ + +* Use sample data when generating subject for display in email profile settings. + +* Convert (most?) basic views to use master view pattern. + + +0.4.27 +------ + +* Change default sortkey for email profiles list. + +* Add 'To' field to email profile settings grid. + + +0.4.26 +------ + +* Add readonly support for email profile settings. + + +0.4.25 +------ + +* Fix bug when 'edbob.permissions' setting is empty. + +* Tweak some things to get Tailbone working on its own. + +* Let subclass of MasterView override the database Session it uses. + + +0.4.24 +------ + +* Render ``DataSyncChange.obtained`` as humanized timestamp within UI. + + +0.4.23 +------ + +* Delete product costs for vendor when deleting vendor. + +* Work around formalchemy config bug, caused by edbob. + +* Add view to show DataSync changes, for basic troubleshooting. + + +0.4.22 +------ + +* Remove format hack which isn't py2.6-friendly. + + +0.4.21 +------ + +* Add "valueless verbs" concept to grid filters. + +* Tweak labels for new grid filter form buttons. + +* Configure logging when starting up. + +* Add HTML5 doctype to base template. + +* More grid filter improvements; add choice/enum/date value renderers. + +* Treat filter by "contains X Y" as "contains X and contains Y". + +* Tweak layout CSS so page body expands to fill screen. + + +0.4.20 +------ + +* Add ``CurrencyFieldRenderer``. + +* Add basic checkbox support to new grids. + +* Add 'Default Filters' and 'Clear Filters' buttons to new grid filters form. + +* Add "Save Defaults" button so user can save personal defaults for any new grid. + +* Fix bug when rendering hidden field in FA fieldset. + +* Remove some unused styles. + +* Various tweaks to support "late login" idea when uploading new batch. + +* Hard-code old grid pagecount settings, to avoid ``edbob.config``. + +* Refactor app configuration to use ``rattail.config.make_config()``. + +* Tweak label formatter instantiation, per rattail changes. + +* Various tweaks to base batch views. + +* Add ``CustomFieldRenderer`` and ``DateFieldRenderer``. + +* Add ``configure_fieldset()`` stub for master view. + +* Add progress indicator to batch execution. + +* Add ability to download batch row data as CSV. + + +0.4.19 +------ + +* Fix progress template, per jQuery CDN changes. + + +0.4.18 +------ + +* Don't show flash message when user logs in. + +* Add core JS/CSS to base template; use CDN instead of cached files. + +* Add support for "new-style grids" and "model master views", and convert the + following views to use it: roles, users, label profiles, settings. Also + overhaul how permissions are registered in app config. + + +0.4.17 +------ + +* Log warning instead of error when refreshing batch fails. + + +0.4.16 +------ + +* Add initial support for email bounce management. + + +0.4.15 +------ + +* Fix missing import bug. + + +0.4.14 +------ + +* Make anchor tags with 'button' class render as jQuery UI buttons. + +* Tweak ``app.make_rattail_config()`` to allow caller to define some settings. + +* Add ``display_name`` field to employee CRUD view. + +* Allow batch handler to disable the Execute button. + +* Add ``StoreFieldRenderer`` and ``DecimalFieldRenderer``. + +* Tweak how default filter config is handled for batch grid views. + +* Add list of assigned users to role view page. + +* Add products autocomplete view. + +* Add ``rattail_config`` attribute to base ``View`` class. + +* Fix timezone issues with ``util.pretty_datetime()`` function. + +* Add some custom FormEncode validators. + + +0.4.13 +------ + +* Fix query bugs for batch row grid views (add join support). + +* Make vendor field renderer show ID in readonly mode. + +* Change permission requirement for refreshing a batch's data. + +* Add flash message when any batch executes successfully. + +* Add autocomplete view for current employees. + +* Add autocomplete employee field renderer. + +* Fix usage of ``Product.unit_of_measure`` vs. ``Product.weighed``. + + +0.4.12 +------ + +* Fix bug when creating batch from product query. + + +0.4.11 +------ + +* Tweak old-style batch execution call. + + +0.4.10 +------ + +* Add 'fake_error' view to test exception handling. + +* Add ability to view details (i.e. all fields) of a batch row. + +* Fix bulk delete of batch rows, to set 'removed' flag instead. + +* Fix vendor invoice validation bug. + +* Add dept. number and friends to product details page. + +* Add "extra panels" customization hook to product details template. + + +0.4.9 +----- + +* Hide "print labels" column on products list view if so configured. + + +0.4.8 +----- + +* Fix permission for deposit link list/search view. + +* Fix permission for taxes list/search view. + + +0.4.7 +----- + +* Add views for deposit links, taxes; update product view. + +* Add some new vendor and product fields. + +* Add panels to product details view, etc. + +* Fix login so user is sent to their target page after authentication. + +* Don't allow edit of vendor and effective date in catalog batches. + +* Add shared GPC search filter, use it for product batch rows. + +* Add default ``Grid.iter_rows()`` implementation. + +* Add "save" icon and grid column style. + +* Add ``numeric.js`` script for numeric-only text inputs. + +* Add product UPC to JSON output of 'products.search' view. + + +0.4.6 +----- + +* Add vendor catalog batch importer. + +* Add vendor invoice batch importer. + +* Improve data file handling for file batches. + +* Add download feature for file batches. + +* Add better error handling when batch refresh fails, etc. + +* Add some docs for new batch system. + +* Refactor ``app`` module to promote code sharing. + +* Force grid table background to white. + +* Exclude 'deleted' items from reports. + +* Hide deleted field from product details, according to permissions. + +* Fix embedded grid URL query string bug. + + +0.4.5 +----- + +* Add prettier UPCs to ordering worksheet report. + +* Add case pack field to product CRUD form. + + +0.4.4 +----- + +* Add UI support for ``Product.deleted`` column. + + +0.4.3 +----- + +* More versioning support fixes, to allow on or off. + + +0.4.2 +----- + +* Rework versioning support to allow it to be on or off. + + +0.4.1 +----- + +* Only attempt to count versions for versioned models (CRUD views). + + +0.4.0 +----- + +This version primarily got the bump it did because of the addition of support +for SQLAlchemy-Continuum versioning. There were several other minor changes as +well. + +* Add department to field lists for category views. + +* Change default sort for People grid view. + +* Add category to product CRUD view. + +* Add initial versioning support with SQLAlchemy-Continuum. + + +0.3.28 +------ + +* Add unique username check when creating users. + +* Improve UPC search for rows within batches. + +* New batch system... + + +0.3.27 +------ + +* Fix bug with default search filters for SA grids. + +* Fix bug in product search UPC filter. + +* Ugh, add unwanted jQuery libs to progress template. + +* Add support for integer search filters. + + +0.3.26 +------ + +* Use boolean search filter for batch column filters of 'FLAG' type. + + +0.3.25 +------ + +* Make product UPC search view strip non-digit chars from input. + + +0.3.24 +------ + +* Make ``GPCFieldRenderer`` display check digit separate from main barcode + data. + +* Add ``DateTimeFieldRenderer`` to show human-friendly timestamps. + +* Tweak CRUD form buttons a little. + +* Add grid, CRUD views for ``Setting`` model. + +* Update ``base.css`` with various things from other projects. + +* Fix bug with progress template, when error occurs. + + +0.3.23 +------ + +* Fix bugs when configuring database session within threads. + + +0.3.22 +------ + +* Make ``Store.database_key`` field editable. + +* Add explicit session config within batch threads. + +* Remove cap on installed Pyramid version. + +* Change session progress API. + + +0.3.21 +------ + +* Add monospace font for label printer format command. + + +0.3.20 +------ + +* Refactor some label printing stuff, per rattail changes. + + +0.3.19 +------ + +* Add support for ``Product.not_for_sale`` flag. + + +0.3.18 +------ + +* Add explicit file encoding to all Mako templates. + +* Add "active" filter to users view; enable it by default. + + +0.3.17 +------ + +* Add customer phone autocomplete and customer "info" AJAX view. + +* Allow editing ``User.active`` field. + +* Add Person autocomplete view which restricts to employees only. + + +0.3.16 +------ + +* Add product report codes to the UI. + + +0.3.15 +------ + +* Add experimental soundex filter support to the Customers grid. + + +0.3.14 +------ + +* Add event hook for attaching Rattail ``config`` to new requests. + +* Fix vendor filter/sort issues in products grid. + +* Add ``Family`` and ``Product.family`` to the general grid/crud UI. + +* Add POD image support to product view page. + + +0.3.13 +------ + +* Use global ``Session`` from rattail (again). + +* Apply zope transaction to global Tailbone Session class. + + +0.3.12 +------ + +* Fix customer lookup bug in customer detail view. + +* Add ``SessionProgress`` class, and ``progress`` views. + + +0.3.11 +------ + +* Removed reliance on global ``rattail.db.Session`` class. + + +0.3.10 +------ + +* Changed ``UserFieldRenderer`` to leverage ``User.display_name``. + +* Refactored model imports, etc. + + This is in preparation for using database models only from ``rattail`` + (i.e. no ``edbob``). Mostly the model and enum imports were affected. + +* Removed references to ``edbob.enum``. + + +0.3.9 +----- + +* Added forbidden view. + +* Fixed bug with ``request.has_any_perm()``. + +* Made ``SortableAlchemyGridView`` default to full (100%) width. + +* Refactored ``AutocompleteFieldRenderer``. + + Also improved some organization of renderers. + +* Allow overriding form class/factory for CRUD views. + +* Made ``EnumFieldRenderer`` a proper class. + +* Don't sort values in ``EnumFieldRenderer``. + + The dictionaries used to supply enumeration values should be ``OrderedDict`` + instances if sorting is needed. + +* Added ``Product.family`` to CRUD view. + + +0.3.8 +----- + +* Fixed manifest (whoops). + + +0.3.7 +----- + +* Added some autocomplete Javascript magic. + + Not sure how this got missed the first time around. + +* Added ``products.search`` route/view. + + This is for simple AJAX uses. + +* Fixed grid join map bug. + + +0.3.6 +----- + +* Fixed change password template/form. + + +0.3.5 +----- + +* Added ``forms.alchemy`` module and changed CRUD view to use it. + +* Added progress template. + + +0.3.4 +----- + +* Changed vendor filter in product search to find "any vendor". + + I.e. the current filter is *not* restricted to the preferred vendor only. + Probably should still add one (back) for preferred only as well; hence the + commented code. + + +0.3.3 +----- + +* Major overhaul for standalone operation. + + This removes some of the ``edbob`` reliance, as well as borrowing some + templates and styling etc. from Dtail. + + Stop using ``edbob.db.engine``, stop using all edbob templates, etc. + +* Fix authorization policy bug. + + This was really an edge case, but in any event the problem would occur when a + user was logged in, and then that user account was deleted. + +* Added ``global_title()`` to base template. + +* Made logo more easily customizable in login template. + + +0.3.2 +----- + +* Rebranded to Tailbone. + + +0.3.1 +----- + +* Added some tests. + +* Added ``helpers`` module. + + Also added a Pyramid subscriber hook to add the module to the template + renderer context with a key of ``h``. This is nothing really new, but it + overrides the helper provided by ``edbob``, and adds a ``pretty_date()`` + function (which maybe isn't a good idea anyway..?). + +* Added ``simpleform`` wildcard import to ``forms`` module. + +* Added autocomplete view and template. + +* Fixed customer group deletion. + + Now any customer associations are dropped first, to avoid database integrity + errors. + +* Stole grids and grid-based views from ``edbob``. + +* Removed several references to ``edbob``. + +* Replaced ``Grid.clickable`` with ``.viewable``. + + Clickable grid rows seemed to be more irritating than useful. Now a view + icon is shown instead. + +* Added style for grid checkbox cells. + +* Fixed FormAlchemy table rendering when underlying session is not primary. + + This was needed for a grid based on a LOC SMS session. + +* Added grid sort arrow images. + +* Improved query modification logic in alchemy grid views. + +* Overhauled report views to allow easier template customization. + +* Improved product UPC search so check digit is optional. + +* Fixed import issue with ``views.reports`` module. + + +0.3a23 +------ + +* Fixed bugs where edit links were appearing for unprivileged users. + +* Added support for product codes. + + These are shown when viewing a product, and may be used to locate a product + via search filters. + + +0.3a22 +------ + +* Removed ``setup.cfg`` file. + +* Added ``Session`` to ``rattail.pyramid`` namespace. + +* Added Email Address field to Vendor CRUD views. + +* Added extra key lookups for customer and product routes. + + Now the CRUD routes for these objects can leverage UUIDs of various related + objects in addition to the primary object. More should be done with this, + but at least we have a start. + +* Replaced ``forms`` module with subpackage; added some initial goodies (many + of which are currently just imports from ``edbob``). + +* Added/edited various CRUD templates for consistency. + +* Modified several view modules so their Pyramid configuration is more + "extensible." This just means routes and views are defined as two separate + steps, so that derived applications may inherit the route definitions if they + so choose. + +* Added Employee CRUD views; added Email Address field to index view. + +* Updated ``people`` view module so it no longer derives from that of + ``edbob``. + +* Added support for, and some implementations of, extra key lookup abilities to + CRUD views. This allows URLs to use a "natural" key (e.g. Customer ID + instead of UUID), for cases where that is more helpful. + +* Product CRUD now uses autocomplete for Brand field. Also, price fields no + longer appear within an editable fieldset. + +* Within Store index view, default sort is now ID instead of Name. + +* Added Contact and Phone Number fields to Vendor CRUD views; added Contact and + Email Address fields to index view. + + +0.3a21 +------ + +- [feature] Added CRUD view and template. + +- [feature] Added ``AutocompleteView``. + +- [feature] Added Person autocomplete view and User CRUD views. + +- [feature] Added ``id`` and ``status`` fields to Employee grid view. + + +0.3a20 +------ + +- [feature] Sorted the Ordering Worksheet by product brand, description. + +0.3a19 +------ + +- [feature] Made batch creation and execution threads aware of + `sys.excepthook`. Updated both instances to use `rattail.threads.Thread` + instead of `threading.Thread`. This way if an exception occurs within the + thread, the registered handler will be invoked. + +0.3a18 +------ + +- [bug] Label profile editing now uses stripping field renderer to avoid + problems with leading/trailing whitespace. + +- [feature] Added Inventory Worksheet report. + +0.3a17 +------ + +- [feature] Added Brand and Size fields to the Ordering Worksheet. Also + tweaked the template styles slightly, and added the ability to override the + template via config. + +- [feature] Added "preferred only" option to Ordering Worksheet. + +0.3a16 +------ + +- [bug] Fixed bug where requesting deletion of non-existent batch row was + redirecting to a non-existent route. + +0.3a15 +------ + +- [bug] Fixed batch grid and CRUD views so that the execution time shows a + pretty (and local) display instead of 24-hour UTC time. + +0.3a14 +------ + +- [feature] Added some more CRUD. Mostly this was for departments, + subdepartments, brands and products. This was rather ad-hoc and still is + probably far from complete. + +- [general] Changed main batch route. + +- [bug] Fixed label profile templates so they properly handle a missing or + invalid printer spec. + +0.3a13 +------ + +- [bug] Fixed bug which prevented UPC search from working on products screen. + +0.3a12 +------ + +- [general] Fixed namespace packages, per ``setuptools`` documentation. + +- [feature] Added support for ``LabelProfile.visible``. This field may now be + edited, and it is honored when displaying the list of available profiles to + be used for printing from the products page. + +- [bug] Fixed bug where non-numeric data entered in the UPC search field on the + products page was raising an error. + +0.3a11 +------ + +- [bug] Fixed product label printing to handle any uncaught exception, and + report the error message to the end user. + +0.3a10 +------ + +- [general] Updated category views and templates. These were sorely out of + date. + +0.3a9 +----- + +- Add brands autocomplete view. + +- Add departments autocomplete view. + +- Add ID filter to vendors grid. + +0.3a8 +----- + +- Tweak batch progress indicators. + +- Add "Executed" column, filter to batch grid. + +0.3a7 +----- + +- Add ability to restrict batch providers via config. + +0.3a6 +----- + +- Add Vendor CRUD. + +- Add Brand views. + +0.3a5 +----- + +- Added support for GPC data type. + +- Added eager import of ``rattail.sil`` in ``before_render`` hook. + +- Removed ``rattail.pyramid.util`` module. + +- Added initial batch support: views, templates, creation from Product grid. + +- Added support for ``rattail.LabelProfile`` class. + +- Improved Product grid to include filter/sort on Vendor. + +- Cleaned up dependencies. + +- Added ``rattail.pyramid.includeme()``. + +- Added ``CustomerGroup`` CRUD view (read only). + +- Added hot links to ``Customer`` CRUD view. + +- Added ``Store`` index, CRUD views. + +- Updated ``rattail.pyramid.views.includeme()``. + +- Added ``email_preference`` to ``Customer`` CRUD. + +0.3a4 +----- + +- Update grid and CRUD views per changes in ``edbob``. + +0.3a3 +----- + +- Add price field renderers. + +- Add/tweak lots of views for database models. + +- Add label printing to product list view. + +- Add (some of) ``Product`` CRUD. + +0.3a2 +----- + +- Refactor category views. + +0.3a1 +----- + +- Initial port to Rattail v0.3. diff --git a/docs/api/api/batch/core.rst b/docs/api/api/batch/core.rst new file mode 100644 index 00000000..48d34315 --- /dev/null +++ b/docs/api/api/batch/core.rst @@ -0,0 +1,15 @@ + +``tailbone.api.batch.core`` +=========================== + +.. automodule:: tailbone.api.batch.core + +.. autoclass:: APIBatchMixin + +.. autoclass:: APIBatchView + +.. autoclass:: APIBatchRowView + + .. autoattribute:: editable + + .. autoattribute:: supports_quick_entry diff --git a/docs/api/api/batch/ordering.rst b/docs/api/api/batch/ordering.rst new file mode 100644 index 00000000..4b07e1f2 --- /dev/null +++ b/docs/api/api/batch/ordering.rst @@ -0,0 +1,41 @@ + +``tailbone.api.batch.ordering`` +=============================== + +.. automodule:: tailbone.api.batch.ordering + +.. autoclass:: OrderingBatchViews + + .. autoattribute:: collection_url_prefix + + .. autoattribute:: object_url_prefix + + .. autoattribute:: model_class + + .. autoattribute:: route_prefix + + .. autoattribute:: permission_prefix + + .. autoattribute:: default_handler_spec + + .. automethod:: base_query + + .. automethod:: create_object + +.. autoclass:: OrderingBatchRowViews + + .. autoattribute:: collection_url_prefix + + .. autoattribute:: object_url_prefix + + .. autoattribute:: model_class + + .. autoattribute:: route_prefix + + .. autoattribute:: permission_prefix + + .. autoattribute:: default_handler_spec + + .. autoattribute:: supports_quick_entry + + .. automethod:: update_object 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.rst b/docs/api/forms.rst new file mode 100644 index 00000000..bdeb5cf6 --- /dev/null +++ b/docs/api/forms.rst @@ -0,0 +1,9 @@ + +``tailbone.forms`` +================== + +.. automodule:: tailbone.forms + :members: + +.. autoclass:: tailbone.forms.Form + :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/progress.rst b/docs/api/progress.rst new file mode 100644 index 00000000..83685d47 --- /dev/null +++ b/docs/api/progress.rst @@ -0,0 +1,6 @@ + +``tailbone.progress`` +===================== + +.. automodule:: tailbone.progress + :members: diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst index abafe0c9..d28a1b15 100644 --- a/docs/api/subscribers.rst +++ b/docs/api/subscribers.rst @@ -3,5 +3,4 @@ ======================== .. automodule:: tailbone.subscribers - -.. autofunction:: add_rattail_config_attribute_to_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/batch.vendorcatalog.rst b/docs/api/views/batch.vendorcatalog.rst new file mode 100644 index 00000000..4df51685 --- /dev/null +++ b/docs/api/views/batch.vendorcatalog.rst @@ -0,0 +1,10 @@ + +``tailbone.views.batch.vendorcatalog`` +====================================== + +.. automodule:: tailbone.views.batch.vendorcatalog + +.. autoclass:: VendorCatalogsView + :members: + +.. autofunction:: includeme diff --git a/docs/api/views/core.rst b/docs/api/views/core.rst new file mode 100644 index 00000000..8a68f33f --- /dev/null +++ b/docs/api/views/core.rst @@ -0,0 +1,6 @@ + +``tailbone.views.core`` +======================= + +.. automodule:: tailbone.views.core + :members: diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst index b953fafa..e7de7170 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -68,10 +68,59 @@ override when defining your subclass. Factory callable to be used when creating new grid instances; defaults to :class:`tailbone.grids.Grid`. -.. Methods to Override -.. ------------------- -.. -.. The following is a list of methods which you can override when defining your -.. subclass. -.. -.. .. automethod:: MasterView.get_settings + .. attribute:: MasterView.results_downloadable_csv + + Flag indicating whether the view should allow CSV download of grid data, + i.e. primary search results. + + .. attribute:: MasterView.help_url + + If set, this defines the "default" help URL for all views provided by the + master. Default value for this is simply ``None`` which would mean the + Help button is not shown at all. Note that the master may choose to + override this for certain views, if so that should be done within + :meth:`get_help_url()`. + + .. attribute:: MasterView.version_diff_factory + + Optional factory to use for version diff objects. By default + this is *not set* but a subclass is free to set it. See also + :meth:`get_version_diff_factory()`. + + +Methods to Override +------------------- + +The following is a list of methods which you can override when defining your +subclass. + + .. automethod:: MasterView.editable_instance + + .. .. automethod:: MasterView.get_settings + + .. automethod:: MasterView.get_csv_fields + + .. automethod:: MasterView.get_csv_row + + .. automethod:: MasterView.get_help_url + + .. automethod:: MasterView.get_model_key + + .. automethod:: MasterView.get_version_diff_enums + + .. automethod:: MasterView.get_version_diff_factory + + .. automethod:: MasterView.make_version_diff + + .. automethod:: MasterView.title_for_version + + +Support Methods +--------------- + +The following is a list of methods you should (probably) not need to +override, but may find useful: + + .. automethod:: MasterView.default_edit_url + + .. automethod:: MasterView.get_action_route_kwargs 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/api/views/purchasing.batch.rst b/docs/api/views/purchasing.batch.rst new file mode 100644 index 00000000..9bb62c8b --- /dev/null +++ b/docs/api/views/purchasing.batch.rst @@ -0,0 +1,9 @@ + +``tailbone.views.purchasing.batch`` +=================================== + +.. automodule:: tailbone.views.purchasing.batch + +.. autoclass:: PurchasingBatchView + + .. automethod:: save_edit_row_form diff --git a/docs/api/views/purchasing.ordering.rst b/docs/api/views/purchasing.ordering.rst new file mode 100644 index 00000000..38d46b07 --- /dev/null +++ b/docs/api/views/purchasing.ordering.rst @@ -0,0 +1,15 @@ + +``tailbone.views.purchasing.ordering`` +====================================== + +.. automodule:: tailbone.views.purchasing.ordering + +.. autoclass:: OrderingBatchView + + .. autoattribute:: model_class + + .. autoattribute:: default_handler_spec + + .. automethod:: configure_row_form + + .. automethod:: worksheet_update diff --git a/docs/api/views/vendors.catalogs.rst b/docs/api/views/vendors.catalogs.rst deleted file mode 100644 index 432966e7..00000000 --- a/docs/api/views/vendors.catalogs.rst +++ /dev/null @@ -1,10 +0,0 @@ - -``tailbone.views.vendors.catalogs`` -=================================== - -.. automodule:: tailbone.views.vendors.catalogs - -.. autoclass:: VendorCatalogsView - :members: - -.. autofunction:: includeme 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/concepts/batches.rst b/docs/concepts/batches.rst new file mode 100644 index 00000000..bdf66b11 --- /dev/null +++ b/docs/concepts/batches.rst @@ -0,0 +1,65 @@ + +Data Batches +============ + +.. contents:: :local: + +Data "batches" are one of the most powerful features of Rattail / Tailbone. +However each "batch type" is different, and they usually require custom +development. In all cases they require a Rattail-based app database, for +storage. + + +General Overview +---------------- + +You can think of data batches as a sort of "temporary spreadsheet" feature. +When a batch is created, it is usually populated with rows, from some data +source. The user(s) may then manipulate the batch data as needed, with the +final goal being to "execute" the batch. What execution specifically means +will depend on context, e.g. type of batch, but generally it will "commit" the +"pending changes" which are represented by the batch. + +Note that when a batch is executed, it becomes read-only ("frozen in time") and +at that point may be considered part of an audit trail of sorts. The utility +of this may vary depending on the nature of the batch data. + +Beyond that it's difficult to describe batches very well at this level, +precisely because they're all different. + +.. + This graphic tries to show how batches are created and executed over time. + Note that each batch type is free to target a different system(s) upon + execution. + + TODO: need graphic + + +Batch Tables +------------ + +In most cases the table(s) underlying a particular batch type, have a "static" +schema and must be defined as ORM classes, e.g. within the ``poser.db.model`` +package. + +In some rare cases the batch data (row) table may be dynamic; however the batch +header table must still be defined. + + +Batch Handlers +-------------- + +Once the batch table(s) are present, the next puzzle piece is the batch +handler. Again there is generally (at least) one handler defined for each +batch type. + +The batch "handler" is considered part of the data layer and provides logic for +populating the batch, executing it etc. + + +Batch Views +----------- + +This discussion would not be complete without mentioning the web views for the +batch. Again each batch type will require a custom view(s) although these +"usually" are simple wrappers as most logic is provided by the base view. diff --git a/docs/concepts/config.rst b/docs/concepts/config.rst new file mode 100644 index 00000000..9d7eacb3 --- /dev/null +++ b/docs/concepts/config.rst @@ -0,0 +1,115 @@ + +Configuration +============= + +.. contents:: :local: + +Configuration for an app can come from two sources: configuration file(s), and +the Settings table in the database. + + +Config File Inheritance +----------------------- + +An important thing to understand regarding Rattail config files, is that one +file may "include" another file(s), which in turn may "include" others etc. +Invocation of the app will often require only a single config file to be +specified, since that file may include others as needed. + +For example ``web.conf`` will typically include ``rattail.conf`` but the web +app need only be invoked with ``web.conf`` - config from both files will inform +the app's behavior. + + +Typical Config Files +-------------------- + +A typical Poser (Rattail-based) app will have at the very least, one file named +``rattail.conf`` - this is considered the most fundamental config file. It +will usually define database connections, logging config, and any other "core" +things which would be required for any invocation of the app, regardless of the +environment (e.g. console vs. web). + +Note that even ``rattail.conf`` is free to include other files. This may be +useful for instance, if you have a single site-wide config file which is shared +among all Rattail apps. + +There is no *strict* requirement for having a ``rattail.conf`` file, but these +docs will assume its presence. Here are some other typical files, which the +docs also may reference occasionally: + +**web.conf** - This is the "core" config file for the web app, although it +still includes the ``rattail.conf`` file. In production (running on Apache +etc.) it is specified within the WSGI module which is responsible for +instantiating the web app. When running the development server, it is +specified via command line. + +**quiet.conf** - This is a slight wrapper around ``rattail.conf`` for the sake +of a "quieter" console, when running app commands via console. It may be used +in place of ``rattail.conf`` - i.e. you would specify ``-c quiet.conf`` when +running the command. The only function of this wrapper is to set the level to +INFO for the console logging handler. In practice this hides DEBUG logging +messages which are shown by default when using ``rattail.conf`` as the app +config file. + +**cron.conf** - Another wrapper around ``rattail.conf`` which suppresses +logging even further. The idea is that this config file would be used by cron +jobs; that way the only actual output is warnings and errors, hence cron would +not send email unless something actually went wrong. It may be used in place +of ``rattail.conf`` - i.e. you would specify ``-c cron.conf`` when running the +command. The only function of this wrapper is to set the level to WARNING for +the console logging handler. + +**ignore-changes.conf** - This file is only relevant if your ``rattail.conf`` +says to "record changes" when write activity occurs in the database(s). Note +that this file does *not* include ``rattail.conf`` because it is meant to be +supplemental only. For instance on the command line, you would need to specify +two config files, first ``rattail.conf`` or a suitable alternative, but then +``ignore-changes.conf`` also. If specified, this file will cause changes to be +ignored, i.e. **not recorded** when write activity occurs. + +**without-versioning.conf** - This file is only relevant if your +``rattail.conf`` says to enable "data versioning" when write activity occurs in +the database(s). Note that this file does *not* include ``rattail.conf`` +because it is meant to be supplemental only. For instance on the command line, +you would need to specify two config files, first ``rattail.conf`` or a +suitable alternative, but then ``without-versioning.conf`` also. If specified, +this file will disable the data versioning system entirely. Note that if +versioning is undesirable for a given app run, this is the only way to +effectively disable it; once loaded that feature cannot be disabled. + + +Settings from Database +---------------------- + +The other (often more convenient) source of app configuration is the Settings +table within the app database. Whether or not this table is a valid source for +app configuration, ultimately depends on what the config file(s) has to say +about it. + +Assuming the config file(s) defines a database connection and declares it a +valid source for config values, then the Settings table may contribute to the +running app config. The nice thing about this is that these settings are +checked in real-time. So whereas changing a config file will require an app +restart, any edits to the settings table should take effect immediately. + +Usually the settings table will *override* values found in the config file. +This behavior also is configurable to some extent, and in some cases a config +value may *only* come from a config file and never the settings table. + +An example may help here. If the config file contained the following value: + +.. code-block:: ini + + [poser] + foo = bar + +Then you could create a new Setting in the database with the following fields: + +* **name** = poser.foo +* **value** = baz + +Assuming typical setup, i.e. where settings table may override config file, the +app would consider 'baz' to be the config value. So basically the setting name +must correspond to a combination of the config file "section" name, then a dot, +then the "option" name. diff --git a/docs/concepts/console.rst b/docs/concepts/console.rst new file mode 100644 index 00000000..32912d6a --- /dev/null +++ b/docs/concepts/console.rst @@ -0,0 +1,7 @@ + +Console Commands +================ + +.. contents:: :local: + +TODO diff --git a/docs/concepts/schema.rst b/docs/concepts/schema.rst new file mode 100644 index 00000000..f10436cb --- /dev/null +++ b/docs/concepts/schema.rst @@ -0,0 +1,45 @@ + +Database Schema +=============== + +.. contents:: :local: + +Rattail provides a "core" schema which is assumed to be the foundation of any +Poser app database. + + +Core Tables +----------- + +All tables which are considered part of the Rattail "core" schema, are defined +as ORM classes within the ``rattail.db.model`` package. + +.. note:: + + The Rattail project has its roots in retail grocery-type stores, and its + schema reflects that to a large degree. In practice however the software + may be used to support a wide variety of apps. The next section describes + that a bit more. + + +Customizing the Schema +---------------------- + +Almost certainly a custom app will need some of the core tables, but just as +certainly, it will *not* need others. And to make things even more +interesting, it may need some tables but also need to "supplement" them +somehow, to track additional data for each record etc. + +Any table in the core schema which is *not* needed, may simply be ignored, +i.e. hidden from the app UI etc. + +Any table which is "missing" from core schema, from the custom app's +perspective, should be added as a custom table. + +Also, any table which is "present but missing columns" from the app's +perspective, will require a custom table. In this case each record in the +custom table will "tie back to" the core table record. The custom record will +then supply any additional data for the core record. + +Defining custom tables, and associated tasks, are documented in +:doc:`../schemachange`. diff --git a/docs/conf.py b/docs/conf.py index 4529fcc2..ade4c92a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,36 +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 -exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read()) +from importlib.metadata import version as get_version +project = 'Tailbone' +copyright = '2010 - 2024, Lance Edgar' +author = 'Lance Edgar' +release = get_version('Tailbone') -# 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 --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# -- 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', @@ -38,235 +23,30 @@ extensions = [ 'sphinx.ext.viewcode', ] +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + intersphinx_mapping = { - # TODO: Add this back, when the FA site is back online... - #'formalchemy': ('http://docs.formalchemy.org/formalchemy/', 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'2015, Lance Edgar' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '0.3' -# The full version, including alpha/beta/rc tags. -release = __version__ - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# 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' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# "<project> v<release> documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +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 - -# 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/devenv.rst b/docs/devenv.rst new file mode 100644 index 00000000..c8900f60 --- /dev/null +++ b/docs/devenv.rst @@ -0,0 +1,78 @@ + +Development Environment +======================= + +.. contents:: :local: + +Base System +----------- + +Development for Tailbone in particular is assumed to occur on a Linux machine. +This is because it's assumed that the web app would run on Linux. It should be +possible (presumably) to do either on Windows or Mac but that is not officially +supported. + +Furthermore it is assumed the Linux flavor in use is either Debian or Ubuntu, +or a similar alternative. Presumably any Linux would work although some +details may differ from what's shown here. + +Prerequisites +------------- + +Python +^^^^^^ + +The only supported Python is 2.7. Of course that should already be present on +Linux. + +It usually is required at some point to compile C code for certain Python +extension modules. In practice this means you probably want the Python header +files as well: + +.. code-block:: sh + + sudo apt-get install python-dev + +pip +^^^ + +The only supported Python package manager is ``pip``. This can be installed a +few ways, one of which is: + +.. code-block:: sh + + sudo apt-get install python-pip + +virtualenvwrapper +^^^^^^^^^^^^^^^^^ + +While not technically required, it is recommended to use ``virtualenvwrapper`` +as well. There is more than one way to set this up, e.g.: + +.. code-block:: sh + + sudo apt-get install python-virtualenvwrapper + +The main variable as concerns these docs, is where your virtual environment(s) +will live. If you install virtualenvwrapper via the above command, then most +likely your ``$WORKON_HOME`` environment variable will be set to +``~/.virtualenvs`` - however these docs will assume ``/srv/envs`` instead. +Please adjust any commands as needed. + +PostgreSQL +^^^^^^^^^^ + +The other primary requirement is PostgreSQL. Technically that may be installed +on a separate machine, which allows connection from the development machine. +But of course it will usually just be installed on the dev machine: + +.. code-block:: sh + + sudo apt-get install postgresql + +Regardless of where your PG server lives, you will probably need some extras in +order to compile extensions for the ``psycopg2`` package: + +.. code-block:: sh + + sudo apt-get install libpq-dev diff --git a/docs/images/poser-architecture.png b/docs/images/poser-architecture.png new file mode 100644 index 00000000..7e697990 Binary files /dev/null and b/docs/images/poser-architecture.png differ diff --git a/docs/images/rattail_avatar.png b/docs/images/rattail_avatar.png new file mode 100644 index 00000000..99640af3 Binary files /dev/null and b/docs/images/rattail_avatar.png differ diff --git a/docs/index.rst b/docs/index.rst index 3fbf704e..d964086f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,15 +2,35 @@ Tailbone ======== -Welcome to Tailbone, part of the Rattail project. +Welcome to Tailbone, part of the Rattail project. While the core Rattail +package provides the data layer, the Tailbone package provides the (default, +back-end) web application layer. -The documentation you are currently reading is for the Tailbone web application -package. Some additional information is available on the `website`_. Clearly -not everything is documented yet. Below you can see what has received some +Some additional information is available on the `website`_. Certainly not +everything is documented yet, but here you can see what has received some attention thus far. .. _website: https://rattailproject.org/ +Quick Start for Custom Apps: + +.. toctree:: + :maxdepth: 1 + + structure + devenv + newproject + schemachange + +Concept Guide: + +.. toctree:: + + concepts/config + concepts/console + concepts/schema + concepts/batches + Narrative Documentation: .. toctree:: @@ -22,11 +42,32 @@ Package API: .. toctree:: :maxdepth: 1 + 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/vendors.catalogs + api/views/members + api/views/purchasing.batch + api/views/purchasing.ordering + + +Changelog: + +.. toctree:: + :maxdepth: 1 + + changelog Documentation To-Do diff --git a/docs/newproject.rst b/docs/newproject.rst new file mode 100644 index 00000000..30aaae89 --- /dev/null +++ b/docs/newproject.rst @@ -0,0 +1,154 @@ + +Creating a New Project +====================== + +.. contents:: :local: + +.. highlight:: bash + +This describes the process of creating a new app project based on +Rattail/Tailbone. It assumes you are working from a supported :doc:`devenv`. + +Per convention, this doc uses "Poser" (and ``poser``) to represent the custom +app. Please adjust commands etc. accordingly. See also :doc:`structure`. + + +Create the Virtual Environment +------------------------------ + +First step is simple enough:: + + mkvirtualenv poser + +Then with your new environment activated, install the Tailbone package:: + + pip install Tailbone + + +Create the Project +------------------ + +Now with your environment still activated, ``cd`` to wherever you like +(e.g. ``~/src``) and create a new project skeleton like so:: + + mkdir -p ~/src + cd ~/src + pcreate -s rattail poser + +This will have created a new project at ``~/src/poser`` which you can then edit +as you wish. At some point you will need to "install" this project to the +environment like so (again with environment active):: + + cd ~/src/poser + pip install -e . + + +Setup the App Environment +------------------------- + +Any project based on Rattail will effectively be its own "app" (usually), but +Rattail itself provides some app functionality as well. However all such apps +require config files, usually. If running a web app then you may also need to +have configured a folder for session storage, etc. To hopefully simplify all +this, there are a few commands you should now run, with your virtual +environment still active:: + + rattail make-appdir + cdvirtualenv app + rattail make-config -T rattail + rattail make-config -T quiet + rattail make-config -T web + +This will have created a new 'app' folder in your environment (e.g. at +``/srv/envs/poser/app``) and then created ``rattail.conf`` and ``web.conf`` +files within that app dir. Note that there will be other folders inside the +app dir as well; these are referenced by the config files. + +But you're not done yet... You should likely edit the config files, at the +very least edit ``rattail.conf`` and change the ``default.url`` value (under +``[rattail.db]`` section) which defines the Rattail database connection. + + +Create the Database +------------------- + +If applicable, it's time for that. First you must literally create the user +and database on your PostgreSQL server, e.g.:: + + sudo -u postgres createuser --no-createdb --no-createrole --no-superuser poser + sudo -u postgres psql -c "alter user poser password 'mypassword'" + sudo -u postgres createdb --owner poser poser + +Then you can install the schema; with your virtual environment activated:: + + cdvirtualenv + alembic -c app/rattail.conf upgrade heads + +At this point your 'poser' database should have some empty tables. To confirm, +on your PG server do:: + + sudo -u postgres psql -c '\d' poser + + +Create Admin User +----------------- + +If your intention is to have a web app, or at least to test one, you'll +probably want to create the initial admin user. With your env active:: + + cdvirtualenv + rattail -c app/quiet.conf make-user --admin myusername + +This should prompt you for a password, then create a single user and assign it +to the Administrator role. + + +Install Sample Data +------------------- + +If desired, you can install a bit of sample data to your fresh Rattail +database. With your env active do:: + + cdvirtualenv + rattail -c app/quiet.conf -P import-sample + + +Run Dev Web Server +------------------ + +With all the above in place, you may now run the web server in dev mode:: + + cdvirtualenv + pserve --reload app/web.conf + +And finally..you may browse your new project dev site at http://localhost:9080/ +(unless you changed the port etc.) + + +Schema Migrations +----------------- + +Often a new project will require custom schema additions to track/manage data +unique to the project. Rattail uses `Alembic`_ for handling schema migrations. +General usage of that is documented elsewhere, but a little should be said here +regarding new projects. + +.. _Alembic: https://pypi.python.org/pypi/alembic + +The new project template includes most of an Alembic "repo" for schema +migrations. However there is one step required to really bootstrap it, i.e. to +the point where normal Alembic usage will work: you must create the initial +version script. Before you do this, you should be reasonably happy with any +ORM classes you've defined, as the initial version script will be used to +create that schema. Once you're ready for the script, this command should do +it:: + + cdvirtualenv + bin/alembic -c app/rattail.conf revision --autogenerate --version-path ~/src/poser/poser/db/alembic/versions/ -m 'initial Poser tables' + +You should of course look over and edit the generated script as needed. One +change in particular you should make is to add a branch label, e.g.: + +.. code-block:: python + + branch_labels = ('poser',) diff --git a/docs/schemachange.rst b/docs/schemachange.rst new file mode 100644 index 00000000..5d385b7b --- /dev/null +++ b/docs/schemachange.rst @@ -0,0 +1,63 @@ + +Migrating the Schema +==================== + +.. contents:: :local: + +As development progresses for your custom app, you may need to migrate the +database schema from time to time. + +See also this general discussion of the :doc:`concepts/schema`. + +.. note:: + + The only "safe" migrations are those which add or modify (or remove) + "custom" tables, i.e. those *not* provided by the ``rattail.db.model`` + package. This doc assumes you are aware of this and are only attempting a + safe migration. + + +Modify ORM Classes +------------------ + +First step is to modify the ORM classes defined by your app, so they reflect +the "desired" schema. Typically this will mean editing files under the +``poser.db.model`` package within your source. In particular when adding new +tables, you must be sure to include them within ``poser/db/model/__init__.py``. + +As noted above, only those classes *not* provided by ``rattail.db.model`` +should be modified here, to be safe. If you wish to "extend" an existing +table, you must create a secondary table which ties back to the first via +one-to-one foreign key relationship. + + +Create Migration Script +----------------------- + +Next you will create the Alembic script which is responsible for performing the +schema migration against a database. This is typically done like so: + +.. code-block:: sh + + workon poser + cdvirtualenv + bin/alembic -c app/rattail.conf revision --autogenerate --head poser@head -m "describe migration here" + +This will create a new file under +e.g. ``~/src/poser/poser/db/alembic/versions/``. You should edit this file as +needed to ensure it performs all steps required for the migration. Technically +it should support downgrade as well as upgrade, although in practice that isn't +always required. + + +Upgrade Database Schema +----------------------- + +Once you're happy with the new script, you can apply it against your dev +database with something like: + +.. code-block:: sh + + workon poser + cdvirtualenv + bin/alembic -c app/rattail.conf upgrade heads diff --git a/docs/structure.rst b/docs/structure.rst new file mode 100644 index 00000000..5585f71a --- /dev/null +++ b/docs/structure.rst @@ -0,0 +1,130 @@ + +App Organization & Structure +============================ + +.. contents:: :local: + +Tailbone doesn't try to be an "app" proper. But it does try to provide just +about everything you'd need to make one. These docs assume you are making a +custom app, and will refer to the app as "Poser" to be consistent. In practice +you would give your app a unique name which is meaningful to you. Please +mentally replace "Poser" with your app name as you read. + +.. note:: + + Technically it *is possible* to use Tailbone directly as the app. You may + do so for basic testing of the concepts, but you'd be stuck with Tailbone + logic, with far fewer customization options. All docs will assume a custom + "Poser" app which wraps and (as necessary) overrides Tailbone and Rattail. + + +Architecture +------------ + +In terms of how the Poser app hangs together, here is a conceptual diagram. +Note that all systems on the right-hand side are *external* to Poser, i.e. they +are not "plugins" although Poser may use plugin-like logic for the sake of +integrating with these systems. + +.. image:: images/poser-architecture.png + + +Data Layer vs. Web Layer +^^^^^^^^^^^^^^^^^^^^^^^^ + +While the above graphic doesn't do a great job highlighting the difference, it +will (presumably) help to understand the difference in purpose and function of +Tailbone vs. Rattail packages. + +**Rattail** is the data layer, and is responsible for database connectivity, +table schema information, and some business rules logic (among other things). + +**Tailbone** is the web app layer, and is responsible for presentation and +management of data objects which are made available by Rattail (and others). + +**Poser** is a custom layer which can make use of both data and web app layers, +supplementing each as necessary. In practice the lines may get blurry within +Poser. + +The reason for this distinction between layers, is to allow creation of custom +apps which use only the data layer but not the web app layer. This can be +useful for console-based apps; a traditional GUI app would also be possible +although none is yet planned. + + +File Layout +----------- + +Below is an example file layout for a Poser app project. This tries to be +"complete" and show most kinds of files a typical project may need. In +practice you can usually ignore anything which doesn't apply to your app, +i.e. relatively few of the files shown here are actually required. Of course +some apps may need many more files than this to achieve their goals. + +Note that all files in the root ``poser`` package namespace would correspond to +the "data layer" mentioned above, whereas everything under ``poser.web`` would +of course supply the web app layer. + +.. code-block:: none + + ~/src/poser/ + ├── CHANGELOG.md + ├── docs/ + ├── fabfile.py + ├── MANIFEST.in + ├── poser/ + │ ├── __init__.py + │ ├── batch/ + │ │ ├── __init__.py + │ │ └── foobatch.py + │ ├── commands.py + │ ├── config.py + │ ├── datasync/ + │ ├── db/ + │ │ ├── __init__.py + │ │ ├── alembic/ + │ │ └── model/ + │ │ ├── __init__.py + │ │ ├── batch/ + │ │ │ ├── __init__.py + │ │ │ └── foobatch.py + │ │ └── customers.py + │ ├── emails.py + │ ├── enum.py + │ ├── importing/ + │ │ ├── __init__.py + │ │ ├── model.py + │ │ ├── poser.py + │ │ └── versions.py + │ ├── problems.py + │ ├── templates/ + │ │ └── mail/ + │ │ └── warn_about_foo.html.mako + │ ├── _version.py + │ └── web/ + │ ├── __init__.py + │ ├── app.py + │ ├── static/ + │ │ ├── __init__.py + │ │ ├── css/ + │ │ ├── favicon.ico + │ │ ├── img/ + │ │ └── js/ + │ ├── subscribers.py + │ ├── templates/ + │ │ ├── base.mako + │ │ ├── batch/ + │ │ │ └── foobatch/ + │ │ ├── customers/ + │ │ ├── menu.mako + │ │ └── products/ + │ └── views/ + │ ├── __init__.py + │ ├── batch/ + │ │ ├── __init__.py + │ │ └── foobatch.py + │ ├── common.py + │ ├── customers.py + │ └── products.py + ├── README.rst + └── setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..4a4d5e66 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,103 @@ + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "Tailbone" +version = "0.22.8" +description = "Backoffice Web Application for Rattail" +readme = "README.md" +authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] +license = {text = "GNU GPL v3+"} +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Pyramid", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Office/Business", + "Topic :: Software Development :: Libraries :: Python Modules", +] +requires-python = ">= 3.8" +dependencies = [ + "asgiref", + "colander", + "ColanderAlchemy", + "cornice", + "cornice-swagger", + "deform", + "humanize", + "Mako", + "markdown", + "openpyxl", + "paginate", + "paginate_sqlalchemy", + "passlib", + "Pillow", + "pyramid>=2", + "pyramid_beaker", + "pyramid_deform", + "pyramid_exclog", + "pyramid_fanstatic", + "pyramid_mako", + "pyramid_retry", + "pyramid_tm", + "rattail[db,bouncer]>=0.20.1", + "sa-filters", + "simplejson", + "transaction", + "waitress", + "WebHelpers2", + "WuttaWeb>=0.21.0", + "zope.sqlalchemy>=1.5", +] + + +[project.optional-dependencies] +docs = ["Sphinx", "furo"] +tests = ["coverage", "mock", "pytest", "pytest-cov"] + + +[project.entry-points."paste.app_factory"] +main = "tailbone.app:main" +webapi = "tailbone.webapi:main" + + +[project.entry-points."rattail.cleaners"] +beaker = "tailbone.cleanup:BeakerCleaner" + + +[project.entry-points."rattail.config.extensions"] +tailbone = "tailbone.config:ConfigExtension" + + +[project.urls] +Homepage = "https://rattailproject.org" +Repository = "https://forgejo.wuttaproject.org/rattail/tailbone" +Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues" +Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md" + + +[tool.commitizen] +version_provider = "pep621" +tag_format = "v$version" +update_changelog_on_bump = true + + +[tool.nosetests] +nocapture = 1 +cover-package = "tailbone" +cover-erase = 1 +cover-html = 1 +cover-html-dir = "htmlcov" 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 96e9458d..00000000 --- a/setup.py +++ /dev/null @@ -1,178 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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 - - # For now, let's restrict FormEncode to 1.2 since the 1.3 release - # introduces some deprecation warnings. Once we're running 1.2 everywhere - # in production, we can start looking at adding 1.3 support. - # TODO: Remove this restriction. - 'FormEncode<=1.2.99', # 1.2.4 1.2.6 - - # FormAlchemy 1.5 supports Python 3 but is being a little aggressive about - # it, for our needs...We'll have to stick with 1.4 for now. - u'FormAlchemy<=1.4.99', # 1.4.3 - - # TODO: Pyramid 1.9 looks like it breaks us..? playing it safe for now.. - 'pyramid<1.9', # 1.3b2 1.8.3 - - # apparently 2.0 removes the retry support, in which case we then need - # pyramid_retry .. but that requires pyramid 1.9 ... - 'pyramid_tm<2.0', # 0.3 1.1.1 - - 'ColanderAlchemy', # 0.3.3 - 'deform', # 2.0.4 - 'humanize', # 0.5.1 - 'Mako', # 0.6.2 - 'openpyxl', # 2.4.7 - 'paginate', # 0.5.6 - 'paginate_sqlalchemy', # 0.2.0 - 'pyramid_beaker>=0.6', # 0.6.1 - 'pyramid_debugtoolbar', # 1.0 - 'pyramid_deform', # 0.2 - 'pyramid_exclog', # 0.6 - 'pyramid_mako', # 1.0.2 - 'pyramid_simpleform', # 0.6.1 - 'rattail[db,auth,bouncer]', # 0.5.0 - 'six', # 1.10.0 - 'transaction', # 1.2.0 - 'waitress', # 0.8.1 - 'WebHelpers2', # 2.0 - 'webhelpers2_grid', # 0.1 - 'WTForms', # 2.1 - 'zope.sqlalchemy', # 0.7 -] - - -extras = { - - 'docs': [ - # - # package # low high - - 'Sphinx', # 1.2 - ], - - 'tests': [ - # - # package # low high - - 'coverage', # 3.6 - 'fixture', # 1.5 - 'mock', # 1.0.1 - 'nose', # 1.3.0 - ], - } - - -setup( - name = "Tailbone", - version = __version__, - author = "Lance Edgar", - author_email = "lance@edbob.org", - url = "http://rattailproject.org/", - license = "GNU GPL v3", - description = "Backoffice Web Application for Rattail", - long_description = README, - - classifiers = [ - 'Development Status :: 3 - Alpha', - 'Environment :: Web Environment', - 'Framework :: Pyramid', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Office/Business', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - - install_requires = requires, - extras_require = extras, - tests_require = ['Tailbone[tests]'], - test_suite = 'nose.collector', - - packages = find_packages(exclude=['tests.*', 'tests']), - include_package_data = True, - zip_safe = False, - - entry_points = { - - 'paste.app_factory': [ - 'main = tailbone.app:main', - ], - - 'rattail.config.extensions': [ - 'tailbone = tailbone.config:ConfigExtension', - ], - - 'pyramid.scaffold': [ - 'rattail = tailbone.scaffolds:RattailTemplate', - ], - }, -) diff --git a/tailbone/_version.py b/tailbone/_version.py index 3fee0c32..7095f6c8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,9 @@ # -*- coding: utf-8; -*- -__version__ = '0.6.45' +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + + +__version__ = version('Tailbone') diff --git a/tailbone/forms/renderers/custorders.py b/tailbone/api/__init__.py similarity index 67% rename from tailbone/forms/renderers/custorders.py rename to tailbone/api/__init__.py index 26385582..1fae059f 100644 --- a/tailbone/forms/renderers/custorders.py +++ b/tailbone/api/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -21,22 +21,20 @@ # ################################################################################ """ -Customer order field renderers +Tailbone Web API """ from __future__ import unicode_literals, absolute_import -import formalchemy as fa -from webhelpers2.html import tags +from .core import APIView, api +from .master import APIMasterView, SortColumn +# TODO: remove this +from .master2 import APIMasterView2 -class CustomerOrderFieldRenderer(fa.fields.SelectFieldRenderer): - """ - Renders a link to the customer order - """ - - def render_readonly(self, **kwargs): - order = self.raw_value - if not order: - return '' - return tags.link_to(order, self.request.route_url('custorders.view', uuid=order.uuid)) +def includeme(config): + config.include('tailbone.api.common') + config.include('tailbone.api.auth') + config.include('tailbone.api.customers') + config.include('tailbone.api.upgrades') + config.include('tailbone.api.users') diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py new file mode 100644 index 00000000..a710e30d --- /dev/null +++ b/tailbone/api/auth.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Auth Views +""" + +from cornice import Service + +from tailbone.api import APIView, api +from tailbone.db import Session +from tailbone.auth import login_user, logout_user + + +class AuthenticationView(APIView): + + @api + def check_session(self): + """ + View to serve as "no-op" / ping action to check current user's session. + This will establish a server-side web session for the user if none + exists. Note that this also resets the user's session timer. + """ + data = {'ok': True, 'permissions': []} + if self.request.user: + data['user'] = self.get_user_info(self.request.user) + data['permissions'] = list(self.request.user_permissions) + + # background color may be set per-request, by some apps + if hasattr(self.request, 'background_color') and self.request.background_color: + data['background_color'] = self.request.background_color + else: # otherwise we use the one from config + data['background_color'] = self.rattail_config.get( + 'tailbone', 'background_color') + + # TODO: this seems the best place to return some global app + # settings, but maybe not desirable in all cases..in which + # case should caller need to ask for these explicitly? or + # make a different call altogether to get them..? + app = self.get_rattail_app() + customer_handler = app.get_clientele_handler() + data['settings'] = { + 'customer_field_dropdown': customer_handler.choice_uses_dropdown(), + } + + return data + + @api + def login(self): + """ + API login view. + """ + if self.request.method == 'OPTIONS': + return self.request.response + + username = self.request.json.get('username') + password = self.request.json.get('password') + if not (username and password): + return {'error': "Invalid username or password"} + + # make sure credentials are valid + user = self.authenticate_user(username, password) + if not user: + return {'error': "Invalid username or password"} + + # is there some reason this user should not login? + error = self.why_cant_user_login(user) + if error: + return {'error': error} + + app = self.get_rattail_app() + auth = app.get_auth_handler() + + login_user(self.request, user) + return { + 'ok': True, + 'user': self.get_user_info(user), + 'permissions': list(auth.get_permissions(Session(), user)), + } + + def authenticate_user(self, username, password): + app = self.get_rattail_app() + auth = app.get_auth_handler() + return auth.authenticate_user(Session(), username, password) + + def why_cant_user_login(self, user): + """ + This method is given a ``User`` instance, which represents someone who + is just now trying to login, and has already cleared the basic hurdle + of providing the correct credentials for a user on file. This method + is responsible then, for further verification that this user *should* + in fact be allowed to login to this app node. If the method determines + a reason the user should *not* be allowed to login, then it should + return that reason as a simple string. + """ + + @api + def logout(self): + """ + API logout view. + """ + if self.request.method == 'OPTIONS': + return self.request.response + + logout_user(self.request) + return {'ok': True} + + @api + def become_root(self): + """ + Elevate the current request to 'root' for full system access. + """ + if not self.request.is_admin: + raise self.forbidden() + self.request.user.record_event(self.enum.USER_EVENT_BECOME_ROOT) + self.request.session['is_root'] = True + return { + 'ok': True, + 'user': self.get_user_info(self.request.user), + } + + @api + def stop_root(self): + """ + Lower the current request from 'root' back to normal access. + """ + if not self.request.is_admin: + raise self.forbidden() + self.request.user.record_event(self.enum.USER_EVENT_STOP_ROOT) + self.request.session['is_root'] = False + return { + 'ok': True, + 'user': self.get_user_info(self.request.user), + } + + @api + def change_password(self): + """ + View which allows a user to change their password. + """ + if self.request.method == 'OPTIONS': + return self.request.response + + if not self.request.user: + raise self.forbidden() + + if self.request.user.prevent_password_change and not self.request.is_root: + raise self.forbidden() + + data = self.request.json_body + + # first make sure "current" password is accurate + if not self.authenticate_user(self.request.user, data['current_password']): + return {'error': "The current/old password you provided is incorrect"} + + # okay then, set new password + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, data['new_password']) + return { + 'ok': True, + 'user': self.get_user_info(self.request.user), + } + + @classmethod + def defaults(cls, config): + cls._auth_defaults(config) + + @classmethod + def _auth_defaults(cls, config): + + # session + check_session = Service(name='check_session', path='/session') + check_session.add_view('GET', 'check_session', klass=cls) + config.add_cornice_service(check_session) + + # login + login = Service(name='login', path='/login') + login.add_view('POST', 'login', klass=cls) + config.add_cornice_service(login) + + # logout + logout = Service(name='logout', path='/logout') + logout.add_view('POST', 'logout', klass=cls) + config.add_cornice_service(logout) + + # become root + become_root = Service(name='become_root', path='/become-root') + become_root.add_view('POST', 'become_root', klass=cls) + config.add_cornice_service(become_root) + + # stop root + stop_root = Service(name='stop_root', path='/stop-root') + stop_root.add_view('POST', 'stop_root', klass=cls) + config.add_cornice_service(stop_root) + + # change password + change_password = Service(name='change_password', path='/change-password') + change_password.add_view('POST', 'change_password', klass=cls) + config.add_cornice_service(change_password) + + +def defaults(config, **kwargs): + base = globals() + + AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView']) + AuthenticationView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/forms2/__init__.py b/tailbone/api/batch/__init__.py similarity index 87% rename from tailbone/forms2/__init__.py rename to tailbone/api/batch/__init__.py index 5e1ffcae..bdf58438 100644 --- a/tailbone/forms2/__init__.py +++ b/tailbone/api/batch/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2019 Lance Edgar # # This file is part of Rattail. # @@ -21,9 +21,9 @@ # ################################################################################ """ -Forms Library +Tailbone Web API - Batches """ from __future__ import unicode_literals, absolute_import -from .core import Form +from .core import APIBatchView, APIBatchRowView, BatchAPIMasterView diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py new file mode 100644 index 00000000..f7bc9333 --- /dev/null +++ b/tailbone/api/batch/core.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Batch Views +""" + +import logging +import warnings + +from cornice import Service + +from tailbone.api import APIMasterView + + +log = logging.getLogger(__name__) + + +class APIBatchMixin(object): + """ + Base class for all API views which are meant to handle "batch" *and/or* + "batch row" data. + """ + + def get_batch_class(self): + model_class = self.get_model_class() + if hasattr(model_class, '__batch_class__'): + return model_class.__batch_class__ + return model_class + + def get_handler(self): + """ + Returns a `BatchHandler` instance for the view. All (?) custom batch + API views should define a default handler class; however this may in all + (?) cases be overridden by config also. The specific setting required + to do so will depend on the 'key' for the type of batch involved, e.g. + assuming the 'vendor_catalog' batch: + + .. code-block:: ini + + [rattail.batch] + vendor_catalog.handler = myapp.batch.vendorcatalog:CustomCatalogHandler + + Note that the 'key' for a batch is generally the same as its primary + table name, although technically it is whatever value returns from the + ``batch_key`` attribute of the main batch model class. + """ + app = self.get_rattail_app() + key = self.get_batch_class().batch_key + return app.get_batch_handler(key, default=self.default_handler_spec) + + +class APIBatchView(APIBatchMixin, APIMasterView): + """ + Base class for all API views which are meant to handle "batch" *and/or* + "batch row" data. + """ + supports_toggle_complete = False + supports_execute = False + + def __init__(self, request, **kwargs): + super(APIBatchView, self).__init__(request, **kwargs) + self.batch_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated; " + "please use `batch_handler` instead", + DeprecationWarning, stacklevel=2) + return self.batch_handler + + def normalize(self, batch): + app = self.get_rattail_app() + created = app.localtime(batch.created, from_utc=True) + + executed = None + if batch.executed: + executed = app.localtime(batch.executed, from_utc=True) + + return { + 'uuid': batch.uuid, + '_str': str(batch), + 'id': batch.id, + 'id_str': batch.id_str, + 'description': batch.description, + 'notes': batch.notes, + 'params': batch.params or {}, + 'rowcount': batch.rowcount, + 'created': str(created), + 'created_display': self.pretty_datetime(created), + 'created_by_uuid': batch.created_by.uuid, + 'created_by_display': str(batch.created_by), + 'complete': batch.complete, + 'status_code': batch.status_code, + 'status_display': batch.STATUS.get(batch.status_code, + str(batch.status_code)), + 'executed': str(executed) if executed else None, + 'executed_display': self.pretty_datetime(executed) if executed else None, + 'executed_by_uuid': batch.executed_by_uuid, + 'executed_by_display': str(batch.executed_by or ''), + 'mutable': self.batch_handler.is_mutable(batch), + } + + def create_object(self, data): + """ + Create a new object instance and populate it with the given data. + + Here we'll invoke the handler for actual batch creation, instead of + typical logic used for simple records. + """ + user = self.request.user + kwargs = dict(data) + kwargs['user'] = user + batch = self.batch_handler.make_batch(self.Session(), **kwargs) + if self.batch_handler.should_populate(batch): + self.batch_handler.do_populate(batch, user) + return batch + + def update_object(self, batch, data): + """ + Logic for updating a main object record. + + Here we want to make sure we set "created by" to the current user, when + creating a new batch. + """ + # we're only concerned with *new* batches here + if not batch.uuid: + + # assign creator; initialize row count + batch.created_by_uuid = self.request.user.uuid + if batch.rowcount is None: + batch.rowcount = 0 + + # then go ahead with usual logic + return super(APIBatchView, self).update_object(batch, data) + + def mark_complete(self): + """ + Mark the given batch as "complete". + """ + batch = self.get_object() + + if batch.executed: + return {'error': "Batch {} has already been executed: {}".format( + batch.id_str, batch.description)} + + if batch.complete: + return {'error': "Batch {} is already marked complete: {}".format( + batch.id_str, batch.description)} + + batch.complete = True + return self._get(obj=batch) + + def mark_incomplete(self): + """ + Mark the given batch as "incomplete". + """ + batch = self.get_object() + + if batch.executed: + return {'error': "Batch {} has already been executed: {}".format( + batch.id_str, batch.description)} + + if not batch.complete: + return {'error': "Batch {} is already marked incomplete: {}".format( + batch.id_str, batch.description)} + + batch.complete = False + return self._get(obj=batch) + + def execute(self): + """ + Execute the given batch. + """ + batch = self.get_object() + + if batch.executed: + return {'error': "Batch {} has already been executed: {}".format( + batch.id_str, batch.description)} + + kwargs = dict(self.request.json_body) + kwargs.pop('user', None) + kwargs.pop('progress', None) + result = self.batch_handler.do_execute(batch, self.request.user, **kwargs) + return {'ok': bool(result), 'batch': self.normalize(batch)} + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_defaults(config) + + @classmethod + def _batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + if cls.supports_toggle_complete: + + # mark complete + mark_complete = Service(name='{}.mark_complete'.format(route_prefix), + path='{}/{{uuid}}/mark-complete'.format(object_url_prefix)) + mark_complete.add_view('POST', 'mark_complete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_complete) + + # mark incomplete + mark_incomplete = Service(name='{}.mark_incomplete'.format(route_prefix), + path='{}/{{uuid}}/mark-incomplete'.format(object_url_prefix)) + mark_incomplete.add_view('POST', 'mark_incomplete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_incomplete) + + if cls.supports_execute: + + # execute batch + execute = Service(name='{}.execute'.format(route_prefix), + path='{}/{{uuid}}/execute'.format(object_url_prefix)) + execute.add_view('POST', 'execute', klass=cls, + permission='{}.execute'.format(permission_prefix)) + config.add_cornice_service(execute) + + +# TODO: deprecate / remove this +BatchAPIMasterView = APIBatchView + + +class APIBatchRowView(APIBatchMixin, APIMasterView): + """ + Base class for all API views which are meant to handle "batch rows" data. + """ + editable = False + supports_quick_entry = False + + def __init__(self, request, **kwargs): + super(APIBatchRowView, self).__init__(request, **kwargs) + self.batch_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated; " + "please use `batch_handler` instead", + DeprecationWarning, stacklevel=2) + return self.batch_handler + + def normalize(self, row): + batch = row.batch + return { + 'uuid': row.uuid, + '_str': str(row), + '_parent_str': str(batch), + '_parent_uuid': batch.uuid, + 'batch_uuid': batch.uuid, + 'batch_id': batch.id, + 'batch_id_str': batch.id_str, + 'batch_description': batch.description, + 'batch_complete': batch.complete, + 'batch_executed': bool(batch.executed), + 'batch_mutable': self.batch_handler.is_mutable(batch), + 'sequence': row.sequence, + 'status_code': row.status_code, + 'status_display': row.STATUS.get(row.status_code, str(row.status_code)), + } + + def update_object(self, row, data): + """ + Supplements the default logic as follows: + + Invokes the batch handler's ``refresh_row()`` method after updating the + row's field data per usual. + """ + if not self.batch_handler.is_mutable(row.batch): + return {'error': "Batch is not mutable"} + + # update row per usual + row = super(APIBatchRowView, self).update_object(row, data) + + # okay now we apply handler refresh logic + self.batch_handler.refresh_row(row) + return row + + def delete_object(self, row): + """ + Overrides the default logic as follows: + + Delegates deletion of the row to the batch handler. + """ + self.batch_handler.do_remove_row(row) + + def quick_entry(self): + """ + View for handling "quick entry" user input, for a batch. + """ + data = self.request.json_body + + uuid = data['batch_uuid'] + batch = self.Session.get(self.get_batch_class(), uuid) + if not batch: + raise self.notfound() + + entry = data['quick_entry'] + + try: + row = self.batch_handler.quick_entry(self.Session(), batch, entry) + except Exception as error: + log.warning("quick entry failed for '%s' batch %s: %s", + self.batch_handler.batch_key, batch.id_str, entry, + exc_info=True) + msg = str(error) + if not msg and isinstance(error, NotImplementedError): + msg = "Feature is not implemented" + return {'error': msg} + + if not row: + return {'error': "Could not identify product"} + + self.Session.flush() + result = self._get(obj=row) + result['ok'] = True + return result + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_row_defaults(config) + + @classmethod + def _batch_row_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + + if cls.supports_quick_entry: + + # quick entry + quick_entry = Service(name='{}.quick_entry'.format(route_prefix), + path='{}/quick-entry'.format(collection_url_prefix)) + quick_entry.add_view('POST', 'quick_entry', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(quick_entry) diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py new file mode 100644 index 00000000..22b67e54 --- /dev/null +++ b/tailbone/api/batch/inventory.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Inventory Batches +""" + +import decimal + +import sqlalchemy as sa + +from rattail import pod +from rattail.db.model import InventoryBatch, InventoryBatchRow + +from cornice import Service + +from tailbone.api.batch import APIBatchView, APIBatchRowView + + +class InventoryBatchViews(APIBatchView): + + model_class = InventoryBatch + default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' + route_prefix = 'inventory' + permission_prefix = 'batch.inventory' + collection_url_prefix = '/inventory-batches' + object_url_prefix = '/inventory-batch' + supports_toggle_complete = True + + def normalize(self, batch): + data = super().normalize(batch) + + data['mode'] = batch.mode + data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode) + if data['mode_display'] is None and batch.mode is not None: + data['mode_display'] = str(batch.mode) + + data['reason_code'] = batch.reason_code + + return data + + def count_modes(self): + """ + Retrieve info about the available batch count modes. + """ + permission_prefix = self.get_permission_prefix() + if self.request.is_root: + modes = self.batch_handler.get_count_modes() + else: + modes = self.batch_handler.get_allowed_count_modes( + self.Session(), self.request.user, + permission_prefix=permission_prefix) + return modes + + def adjustment_reasons(self): + """ + Retrieve info about the available "reasons" for inventory adjustment + batches. + """ + raw_reasons = self.batch_handler.get_adjustment_reasons(self.Session()) + reasons = [] + for reason in raw_reasons: + reasons.append({ + 'uuid': reason.uuid, + 'code': reason.code, + 'description': reason.description, + 'hidden': reason.hidden, + }) + return reasons + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_defaults(config) + cls._inventory_defaults(config) + + @classmethod + def _inventory_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + + # get count modes + count_modes = Service(name='{}.count_modes'.format(route_prefix), + path='{}/count-modes'.format(collection_url_prefix)) + count_modes.add_view('GET', 'count_modes', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(count_modes) + + # get adjustment reasons + adjustment_reasons = Service(name='{}.adjustment_reasons'.format(route_prefix), + path='{}/adjustment-reasons'.format(collection_url_prefix)) + adjustment_reasons.add_view('GET', 'adjustment_reasons', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(adjustment_reasons) + + +class InventoryBatchRowViews(APIBatchRowView): + + model_class = InventoryBatchRow + default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' + route_prefix = 'inventory.rows' + permission_prefix = 'batch.inventory' + collection_url_prefix = '/inventory-batch-rows' + object_url_prefix = '/inventory-batch-row' + editable = True + supports_quick_entry = True + + def normalize(self, row): + batch = row.batch + data = super().normalize(row) + app = self.get_rattail_app() + + data['item_id'] = row.item_id + data['upc'] = str(row.upc) + data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name + data['description'] = row.description + data['size'] = row.size + data['full_description'] = row.product.full_description if row.product else row.description + data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None + data['case_quantity'] = app.render_quantity(row.case_quantity or 1) + + data['cases'] = row.cases + data['units'] = row.units + data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + data['quantity_display'] = "{} {}".format( + app.render_quantity(row.cases or row.units), + 'CS' if row.cases else data['unit_uom']) + + data['allow_cases'] = self.batch_handler.allow_cases(batch) + + return data + + def update_object(self, row, data): + """ + Supplements the default logic as follows: + + Converts certain fields within the data, to proper "native" types. + """ + data = dict(data) + + # convert some data types as needed + if 'cases' in data: + if data['cases'] == '': + data['cases'] = None + elif data['cases']: + data['cases'] = decimal.Decimal(data['cases']) + if 'units' in data: + if data['units'] == '': + data['units'] = None + elif data['units']: + data['units'] = decimal.Decimal(data['units']) + + # update row per usual + try: + row = super().update_object(row, data) + except sa.exc.DataError as error: + # detect when user scans barcode for cases/units field + if hasattr(error, 'orig'): + orig = type(error.orig) + if hasattr(orig, '__name__'): + # nb. this particular error is from psycopg2 + if orig.__name__ == 'NumericValueOutOfRange': + return {'error': "Numeric value out of range"} + raise + return row + + +def defaults(config, **kwargs): + base = globals() + + InventoryBatchViews = kwargs.get('InventoryBatchViews', base['InventoryBatchViews']) + InventoryBatchViews.defaults(config) + + InventoryBatchRowViews = kwargs.get('InventoryBatchRowViews', base['InventoryBatchRowViews']) + InventoryBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py new file mode 100644 index 00000000..4f154b21 --- /dev/null +++ b/tailbone/api/batch/labels.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Label Batches +""" + +from rattail.db import model + +from tailbone.api.batch import APIBatchView, APIBatchRowView + + +class LabelBatchViews(APIBatchView): + + model_class = model.LabelBatch + default_handler_spec = 'rattail.batch.labels:LabelBatchHandler' + route_prefix = 'labelbatchviews' + permission_prefix = 'labels.batch' + collection_url_prefix = '/label-batches' + object_url_prefix = '/label-batch' + supports_toggle_complete = True + + +class LabelBatchRowViews(APIBatchRowView): + + model_class = model.LabelBatchRow + default_handler_spec = 'rattail.batch.labels:LabelBatchHandler' + route_prefix = 'api.label_batch_rows' + permission_prefix = 'labels.batch' + collection_url_prefix = '/label-batch-rows' + object_url_prefix = '/label-batch-row' + supports_quick_entry = True + + def normalize(self, row): + batch = row.batch + data = super().normalize(row) + + data['item_id'] = row.item_id + data['upc'] = str(row.upc) + data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name + data['description'] = row.description + data['size'] = row.size + data['full_description'] = row.product.full_description if row.product else row.description + return data + + +def defaults(config, **kwargs): + base = globals() + + LabelBatchViews = kwargs.get('LabelBatchViews', base['LabelBatchViews']) + LabelBatchViews.defaults(config) + + LabelBatchRowViews = kwargs.get('LabelBatchRowViews', base['LabelBatchRowViews']) + LabelBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py new file mode 100644 index 00000000..204be8ad --- /dev/null +++ b/tailbone/api/batch/ordering.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Ordering Batches + +These views expose the basic CRUD interface to "ordering" batches, for the web +API. +""" + +import datetime +import logging + +import sqlalchemy as sa + +from rattail.db.model import PurchaseBatch, PurchaseBatchRow + +from cornice import Service + +from tailbone.api.batch import APIBatchView, APIBatchRowView + + +log = logging.getLogger(__name__) + + +class OrderingBatchViews(APIBatchView): + + model_class = PurchaseBatch + default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + route_prefix = 'orderingbatchviews' + permission_prefix = 'ordering' + collection_url_prefix = '/ordering-batches' + object_url_prefix = '/ordering-batch' + supports_toggle_complete = True + supports_execute = True + + def base_query(self): + """ + Modifies the default logic as follows: + + Adds a condition to the query, to ensure only purchase batches with + "ordering" mode are returned. + """ + model = self.model + query = super().base_query() + query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING) + return query + + def normalize(self, batch): + data = super().normalize(batch) + + data['vendor_uuid'] = batch.vendor.uuid + data['vendor_display'] = str(batch.vendor) + + data['department_uuid'] = batch.department_uuid + data['department_display'] = str(batch.department) if batch.department else None + + data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0) + data['ship_method'] = batch.ship_method + data['notes_to_vendor'] = batch.notes_to_vendor + return data + + def create_object(self, data): + """ + Modifies the default logic as follows: + + Sets the mode to "ordering" for the new batch. + """ + data = dict(data) + if not data.get('vendor_uuid'): + raise ValueError("You must specify the vendor") + data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING + batch = super().create_object(data) + return batch + + def worksheet(self): + """ + Returns primary data for the Ordering Worksheet view. + """ + batch = self.get_object() + if batch.executed: + raise self.forbidden() + + app = self.get_rattail_app() + + # TODO: much of the logic below was copied from the traditional master + # view for ordering batches. should maybe let them share it somehow? + + # organize existing batch rows by product + order_items = {} + for row in batch.active_rows(): + order_items[row.product_uuid] = row + + # organize vendor catalog costs by dept / subdept + departments = {} + costs = self.batch_handler.get_order_form_costs(self.Session(), batch.vendor) + costs = self.batch_handler.sort_order_form_costs(costs) + costs = list(costs) # we must have a stable list for the rest of this + self.batch_handler.decorate_order_form_costs(batch, costs) + for cost in costs: + + department = cost.product.department + if department: + department_dict = departments.setdefault(department.uuid, { + 'uuid': department.uuid, + 'number': department.number, + 'name': department.name, + }) + else: + if None not in departments: + departments[None] = { + 'uuid': None, + 'number': None, + 'name': "", + } + department_dict = departments[None] + + subdepartments = department_dict.setdefault('subdepartments', {}) + + subdepartment = cost.product.subdepartment + if subdepartment: + subdepartment_dict = subdepartments.setdefault(subdepartment.uuid, { + 'uuid': subdepartment.uuid, + 'number': subdepartment.number, + 'name': subdepartment.name, + }) + else: + if None not in subdepartments: + subdepartments[None] = { + 'uuid': None, + 'number': None, + 'name': "", + } + subdepartment_dict = subdepartments[None] + + subdept_costs = subdepartment_dict.setdefault('costs', []) + product = cost.product + subdept_costs.append({ + 'uuid': cost.uuid, + 'upc': str(product.upc), + 'upc_pretty': product.upc.pretty() if product.upc else None, + 'brand_name': product.brand.name if product.brand else None, + 'description': product.description, + 'size': product.size, + 'case_size': cost.case_size, + 'uom_display': "LB" if product.weighed else "EA", + 'vendor_item_code': cost.code, + 'preference': cost.preference, + 'preferred': cost.preference == 1, + 'unit_cost': cost.unit_cost, + 'unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost.unit_cost is not None else "", + # TODO + # 'cases_ordered': None, + # 'units_ordered': None, + # 'po_total': None, + # 'po_total_display': None, + }) + + # sort the (sub)department groupings + sorted_departments = [] + for dept in sorted(departments.values(), key=lambda d: d['name']): + dept['subdepartments'] = sorted(dept['subdepartments'].values(), + key=lambda s: s['name']) + sorted_departments.append(dept) + + # fetch recent purchase history, sort/pad for template convenience + history = self.batch_handler.get_order_form_history(batch, costs, 6) + for i in range(6 - len(history)): + history.append(None) + history = list(reversed(history)) + # must convert some date objects to string, for JSON sake + for h in history: + if not h: + continue + purchase = h.get('purchase') + if purchase: + dt = purchase.get('date_ordered') + if dt and isinstance(dt, datetime.date): + purchase['date_ordered'] = app.render_date(dt) + dt = purchase.get('date_received') + if dt and isinstance(dt, datetime.date): + purchase['date_received'] = app.render_date(dt) + + return { + 'batch': self.normalize(batch), + 'departments': departments, + 'sorted_departments': sorted_departments, + 'history': history, + } + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_defaults(config) + cls._ordering_batch_defaults(config) + + @classmethod + def _ordering_batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # worksheet + worksheet = Service(name='{}.worksheet'.format(route_prefix), + path='{}/{{uuid}}/worksheet'.format(object_url_prefix)) + worksheet.add_view('GET', 'worksheet', klass=cls, + permission='{}.worksheet'.format(permission_prefix)) + config.add_cornice_service(worksheet) + + +class OrderingBatchRowViews(APIBatchRowView): + + model_class = PurchaseBatchRow + default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + route_prefix = 'ordering.rows' + permission_prefix = 'ordering' + collection_url_prefix = '/ordering-batch-rows' + object_url_prefix = '/ordering-batch-row' + supports_quick_entry = True + editable = True + + def normalize(self, row): + data = super().normalize(row) + app = self.get_rattail_app() + batch = row.batch + + data['item_id'] = row.item_id + data['upc'] = str(row.upc) + data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name + data['description'] = row.description + data['size'] = row.size + data['full_description'] = row.product.full_description if row.product else row.description + + # # only provide image url if so configured + # if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): + # data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None + + # unit_uom can vary by product + data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + + data['case_quantity'] = row.case_quantity + data['cases_ordered'] = row.cases_ordered + data['units_ordered'] = row.units_ordered + data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False) + data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False) + + data['po_unit_cost'] = row.po_unit_cost + data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None + data['po_total_calculated'] = row.po_total_calculated + data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None + data['status_code'] = row.status_code + data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code)) + + return data + + def update_object(self, row, data): + """ + Overrides the default logic as follows: + + So far, we only allow updating the ``cases_ordered`` and/or + ``units_ordered`` quantities; therefore ``data`` should have one or + both of those keys. + + This data is then passed to the + :meth:`~rattail:rattail.batch.purchase.PurchaseBatchHandler.update_row_quantity()` + method of the batch handler. + + Note that the "normal" logic for this method is not invoked at all. + """ + if not self.batch_handler.is_mutable(row.batch): + return {'error': "Batch is not mutable"} + + try: + self.batch_handler.update_row_quantity(row, **data) + self.Session.flush() + except Exception as error: + log.warning("update_row_quantity failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} + + return row + + +def defaults(config, **kwargs): + base = globals() + + OrderingBatchViews = kwargs.get('OrderingBatchViews', base['OrderingBatchViews']) + OrderingBatchViews.defaults(config) + + OrderingBatchRowViews = kwargs.get('OrderingBatchRowViews', base['OrderingBatchRowViews']) + OrderingBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py new file mode 100644 index 00000000..b23bff55 --- /dev/null +++ b/tailbone/api/batch/receiving.py @@ -0,0 +1,492 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Receiving Batches +""" + +import logging + +import humanize +import sqlalchemy as sa + +from rattail.db.model import PurchaseBatch, PurchaseBatchRow + +from cornice import Service +from deform import widget as dfwidget + +from tailbone import forms +from tailbone.api.batch import APIBatchView, APIBatchRowView +from tailbone.forms.receiving import ReceiveRow + + +log = logging.getLogger(__name__) + + +class ReceivingBatchViews(APIBatchView): + + model_class = PurchaseBatch + default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + route_prefix = 'receivingbatchviews' + permission_prefix = 'receiving' + collection_url_prefix = '/receiving-batches' + object_url_prefix = '/receiving-batch' + supports_toggle_complete = True + supports_execute = True + + def base_query(self): + model = self.app.model + query = super().base_query() + query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) + return query + + def normalize(self, batch): + data = super().normalize(batch) + + data['vendor_uuid'] = batch.vendor.uuid + data['vendor_display'] = str(batch.vendor) + + data['department_uuid'] = batch.department_uuid + data['department_display'] = str(batch.department) if batch.department else None + + data['po_number'] = batch.po_number + data['po_total'] = batch.po_total + data['invoice_total'] = batch.invoice_total + data['invoice_total_calculated'] = batch.invoice_total_calculated + + data['can_auto_receive'] = self.batch_handler.can_auto_receive(batch) + + return data + + def create_object(self, data): + data = dict(data) + + # all about receiving mode here + data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING + + # assume "receive from PO" if given a PO key + if data.get('purchase_key'): + data['workflow'] = 'from_po' + + return super().create_object(data) + + def auto_receive(self): + """ + View which handles auto-marking as received, all items within + a pending batch. + """ + batch = self.get_object() + self.batch_handler.auto_receive_all_items(batch) + return self._get(obj=batch) + + def mark_receiving_complete(self): + """ + Mark the given batch as "receiving complete". + """ + batch = self.get_object() + + if batch.executed: + return {'error': "Batch {} has already been executed: {}".format( + batch.id_str, batch.description)} + + if batch.complete: + return {'error': "Batch {} is already marked complete: {}".format( + batch.id_str, batch.description)} + + if batch.receiving_complete: + return {'error': "Receiving is already complete for batch {}: {}".format( + batch.id_str, batch.description)} + + batch.receiving_complete = True + return self._get(obj=batch) + + def eligible_purchases(self): + model = self.app.model + uuid = self.request.params.get('vendor_uuid') + vendor = self.Session.get(model.Vendor, uuid) if uuid else None + if not vendor: + return {'error': "Vendor not found"} + + purchases = self.batch_handler.get_eligible_purchases( + vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING) + + purchases = [self.normalize_eligible_purchase(p) + for p in purchases] + + return {'purchases': purchases} + + def normalize_eligible_purchase(self, purchase): + return self.batch_handler.normalize_eligible_purchase(purchase) + + def render_eligible_purchase(self, purchase): + return self.batch_handler.render_eligible_purchase(purchase) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_defaults(config) + cls._receiving_batch_defaults(config) + + @classmethod + def _receiving_batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # auto_receive + auto_receive = Service(name='{}.auto_receive'.format(route_prefix), + path='{}/{{uuid}}/auto-receive'.format(object_url_prefix)) + auto_receive.add_view('GET', 'auto_receive', klass=cls, + permission='{}.auto_receive'.format(permission_prefix)) + config.add_cornice_service(auto_receive) + + # mark_receiving_complete + mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix), + path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix)) + mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_receiving_complete) + + # eligible purchases + eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix), + path='{}/eligible-purchases'.format(collection_url_prefix)) + eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls, + permission='{}.create'.format(permission_prefix)) + config.add_cornice_service(eligible_purchases) + + +class ReceivingBatchRowViews(APIBatchRowView): + + model_class = PurchaseBatchRow + default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + route_prefix = 'receiving.rows' + permission_prefix = 'receiving' + collection_url_prefix = '/receiving-batch-rows' + object_url_prefix = '/receiving-batch-row' + supports_quick_entry = True + + def make_filter_spec(self): + model = self.app.model + filters = super().make_filter_spec() + if filters: + + # must translate certain convenience filters + orig_filters, filters = filters, [] + for filtr in orig_filters: + + # # is_received + # # NOTE: this is only relevant for truck dump or "from scratch" + # if filtr['field'] == 'is_received' and filtr['op'] == 'eq' and filtr['value'] is True: + # filters.extend([ + # {'or': [ + # {'field': 'cases_received', 'op': '!=', 'value': 0}, + # {'field': 'units_received', 'op': '!=', 'value': 0}, + # ]}, + # ]) + + # is_incomplete + if filtr['field'] == 'is_incomplete' and filtr['op'] == 'eq' and filtr['value'] is True: + # looking for any rows with "ordered" quantity, but where the + # status does *not* signify a "settled" row so to speak + # TODO: would be nice if we had a simple flag to leverage? + filters.extend([ + {'or': [ + {'field': 'cases_ordered', 'op': '!=', 'value': 0}, + {'field': 'units_ordered', 'op': '!=', 'value': 0}, + ]}, + {'field': 'status_code', 'op': 'not_in', 'value': [ + model.PurchaseBatchRow.STATUS_OK, + model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND, + model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS, + ]}, + ]) + + # is_invalid + elif filtr['field'] == 'is_invalid' and filtr['op'] == 'eq' and filtr['value'] is True: + filters.extend([ + {'field': 'status_code', 'op': 'in', 'value': [ + model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND, + model.PurchaseBatchRow.STATUS_COST_NOT_FOUND, + model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN, + model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS, + ]}, + ]) + + # is_unexpected + elif filtr['field'] == 'is_unexpected' and filtr['op'] == 'eq' and filtr['value'] is True: + # looking for any rows which do *not* have "ordered/shipped" quantity + filters.extend([ + {'and': [ + {'or': [ + {'field': 'cases_ordered', 'op': 'is_null'}, + {'field': 'cases_ordered', 'op': '==', 'value': 0}, + ]}, + {'or': [ + {'field': 'units_ordered', 'op': 'is_null'}, + {'field': 'units_ordered', 'op': '==', 'value': 0}, + ]}, + {'or': [ + {'field': 'cases_shipped', 'op': 'is_null'}, + {'field': 'cases_shipped', 'op': '==', 'value': 0}, + ]}, + {'or': [ + {'field': 'units_shipped', 'op': 'is_null'}, + {'field': 'units_shipped', 'op': '==', 'value': 0}, + ]}, + {'or': [ + # but "unexpected" also implies we have some confirmed amount(s) + {'field': 'cases_received', 'op': '!=', 'value': 0}, + {'field': 'units_received', 'op': '!=', 'value': 0}, + {'field': 'cases_damaged', 'op': '!=', 'value': 0}, + {'field': 'units_damaged', 'op': '!=', 'value': 0}, + {'field': 'cases_expired', 'op': '!=', 'value': 0}, + {'field': 'units_expired', 'op': '!=', 'value': 0}, + ]}, + ]}, + ]) + + # is_damaged + elif filtr['field'] == 'is_damaged' and filtr['op'] == 'eq' and filtr['value'] is True: + filters.extend([ + {'or': [ + {'field': 'cases_damaged', 'op': '!=', 'value': 0}, + {'field': 'units_damaged', 'op': '!=', 'value': 0}, + ]}, + ]) + + # is_expired + elif filtr['field'] == 'is_expired' and filtr['op'] == 'eq' and filtr['value'] is True: + filters.extend([ + {'or': [ + {'field': 'cases_expired', 'op': '!=', 'value': 0}, + {'field': 'units_expired', 'op': '!=', 'value': 0}, + ]}, + ]) + + # is_missing + elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True: + filters.extend([ + {'or': [ + {'field': 'cases_missing', 'op': '!=', 'value': 0}, + {'field': 'units_missing', 'op': '!=', 'value': 0}, + ]}, + ]) + + else: # just some filter, use as-is + filters.append(filtr) + + return filters + + def normalize(self, row): + data = super().normalize(row) + model = self.app.model + + batch = row.batch + prodder = self.app.get_products_handler() + + data['product_uuid'] = row.product_uuid + data['item_id'] = row.item_id + data['upc'] = str(row.upc) + data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name + data['description'] = row.description + data['size'] = row.size + data['full_description'] = row.product.full_description if row.product else row.description + + # only provide image url if so configured + if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): + data['image_url'] = prodder.get_image_url(product=row.product, upc=row.upc) + + # unit_uom can vary by product + data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + + data['case_quantity'] = row.case_quantity + data['order_quantities_known'] = batch.order_quantities_known + + data['cases_ordered'] = row.cases_ordered + data['units_ordered'] = row.units_ordered + + data['cases_shipped'] = row.cases_shipped + data['units_shipped'] = row.units_shipped + + data['cases_received'] = row.cases_received + data['units_received'] = row.units_received + + data['cases_damaged'] = row.cases_damaged + data['units_damaged'] = row.units_damaged + + data['cases_expired'] = row.cases_expired + data['units_expired'] = row.units_expired + + data['cases_missing'] = row.cases_missing + data['units_missing'] = row.units_missing + + cases, units = self.batch_handler.get_unconfirmed_counts(row) + data['cases_unconfirmed'] = cases + data['units_unconfirmed'] = units + + data['po_unit_cost'] = row.po_unit_cost + data['po_total'] = row.po_total + + data['invoice_number'] = row.invoice_number + data['invoice_unit_cost'] = row.invoice_unit_cost + data['invoice_total'] = row.invoice_total + data['invoice_total_calculated'] = row.invoice_total_calculated + + data['allow_cases'] = self.batch_handler.allow_cases() + + data['quick_receive'] = self.rattail_config.getbool( + 'rattail.batch', 'purchase.mobile_quick_receive', + default=True) + + if batch.order_quantities_known: + data['quick_receive_all'] = self.rattail_config.getbool( + 'rattail.batch', 'purchase.mobile_quick_receive_all', + default=False) + + # TODO: this was copied from regular view receive_row() method; should merge + if data['quick_receive'] and data.get('quick_receive_all'): + if data['allow_cases']: + data['quick_receive_uom'] = 'CS' + raise NotImplementedError("TODO: add CS support for quick_receive_all") + else: + data['quick_receive_uom'] = data['unit_uom'] + accounted_for = self.batch_handler.get_units_accounted_for(row) + remainder = self.batch_handler.get_units_ordered(row) - accounted_for + + if accounted_for: + # some product accounted for; button should receive "remainder" only + if remainder: + remainder = self.app.render_quantity(remainder) + data['quick_receive_quantity'] = remainder + data['quick_receive_text'] = "Receive Remainder ({} {})".format( + remainder, data['unit_uom']) + else: + # unless there is no remainder, in which case disable it + data['quick_receive'] = False + + else: # nothing yet accounted for, button should receive "all" + if not remainder: + log.warning("quick receive remainder is empty for row %s", row.uuid) + remainder = self.app.render_quantity(remainder) + data['quick_receive_quantity'] = remainder + data['quick_receive_text'] = "Receive ALL ({} {})".format( + remainder, data['unit_uom']) + + data['unexpected_alert'] = None + if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered: + warn = True + if batch.is_truck_dump_parent() and row.product: + uuids = [child.uuid for child in batch.truck_dump_children] + if uuids: + count = self.Session.query(model.PurchaseBatchRow)\ + .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\ + .filter(model.PurchaseBatchRow.product == row.product)\ + .count() + if count: + warn = False + if warn: + data['unexpected_alert'] = "This item was NOT on the original purchase order." + + # TODO: surely the caller of API should determine this flag? + # maybe alert user if they've already received some of this product + alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received', + default=False) + if alert_received: + data['received_alert'] = None + if self.batch_handler.get_units_confirmed(row): + msg = "You have already received some of this product; last update was {}.".format( + humanize.naturaltime(self.app.make_utc() - row.modified)) + data['received_alert'] = msg + + return data + + def receive(self): + """ + View which handles "receiving" against a particular batch row. + """ + model = self.app.model + + # first do basic input validation + schema = ReceiveRow().bind(session=self.Session()) + form = forms.Form(schema=schema, request=self.request) + # TODO: this seems hacky, but avoids "complex" date value parsing + form.set_widget('expiration_date', dfwidget.TextInputWidget()) + if not form.validate(): + log.warning("form did not validate: %s", + form.make_deform_form().error) + return {'error': "Form did not validate"} + + # fetch / validate row object + row = self.Session.get(model.PurchaseBatchRow, form.validated['row']) + if row is not self.get_object(): + return {'error': "Specified row does not match the route!"} + + # handler takes care of the row receiving logic for us + kwargs = dict(form.validated) + del kwargs['row'] + try: + self.batch_handler.receive_row(row, **kwargs) + self.Session.flush() + except Exception as error: + log.warning("receive() failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} + + return self._get(obj=row) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_row_defaults(config) + cls._receiving_batch_row_defaults(config) + + @classmethod + def _receiving_batch_row_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # receive (row) + receive = Service(name='{}.receive'.format(route_prefix), + path='{}/{{uuid}}/receive'.format(object_url_prefix)) + receive.add_view('POST', 'receive', klass=cls, + permission='{}.edit_row'.format(permission_prefix)) + config.add_cornice_service(receive) + + +def defaults(config, **kwargs): + base = globals() + + ReceivingBatchViews = kwargs.get('ReceivingBatchViews', base['ReceivingBatchViews']) + ReceivingBatchViews.defaults(config) + + ReceivingBatchRowViews = kwargs.get('ReceivingBatchRowViews', base['ReceivingBatchRowViews']) + ReceivingBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/common.py b/tailbone/api/common.py new file mode 100644 index 00000000..6cacfb06 --- /dev/null +++ b/tailbone/api/common.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - "Common" Views +""" + +from collections import OrderedDict + +from rattail.util import get_pkg_version + +from cornice import Service +from cornice.service import get_services +from cornice_swagger import CorniceSwagger + +from tailbone import forms +from tailbone.forms.common import Feedback +from tailbone.api import APIView, api +from tailbone.db import Session + + +class CommonView(APIView): + """ + Misc. "common" views for the API. + + .. attribute:: feedback_email_key + + This is the email key which will be used when sending "user feedback" + email. Default value is ``'user_feedback'``. + """ + feedback_email_key = 'user_feedback' + + @api + def about(self): + """ + Generic view to show "about project" info page. + """ + packages = self.get_packages() + return { + 'project_title': self.get_project_title(), + 'project_version': self.get_project_version(), + 'packages': packages, + 'package_names': list(packages), + } + + def get_project_title(self): + app = self.get_rattail_app() + return app.get_title() + + def get_project_version(self): + app = self.get_rattail_app() + return app.get_version() + + def get_packages(self): + """ + Should return the full set of packages which should be displayed on the + 'about' page. + """ + return OrderedDict([ + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), + ]) + + @api + def feedback(self): + """ + View to handle user feedback form submits. + """ + app = self.get_rattail_app() + model = self.model + # TODO: this logic was copied from tailbone.views.common and is largely + # identical; perhaps should merge somehow? + schema = Feedback().bind(session=Session()) + form = forms.Form(schema=schema, request=self.request) + if form.validate(): + data = dict(form.validated) + + # figure out who the sending user is, if any + if self.request.user: + data['user'] = self.request.user + elif data['user']: + data['user'] = Session.get(model.User, data['user']) + + # TODO: should provide URL to view user + if data['user']: + data['user_url'] = '#' # TODO: could get from config? + + data['client_ip'] = self.request.client_addr + email_key = data['email_key'] or self.feedback_email_key + app.send_email(email_key, data=data) + return {'ok': True} + + return {'error': "Form did not validate!"} + + def swagger(self): + doc = CorniceSwagger(get_services()) + app = self.get_rattail_app() + spec = doc.generate(f"{app.get_node_title()} API docs", + app.get_version(), + base_path='/api') # TODO + return spec + + @classmethod + def defaults(cls, config): + cls._common_defaults(config) + + @classmethod + def _common_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + app = rattail_config.get_app() + + # about + about = Service(name='about', path='/about') + about.add_view('GET', 'about', klass=cls) + config.add_cornice_service(about) + + # feedback + feedback = Service(name='feedback', path='/feedback') + feedback.add_view('POST', 'feedback', klass=cls, + permission='common.feedback') + config.add_cornice_service(feedback) + + # swagger + swagger = Service(name='swagger', + path='/swagger.json', + description=f"OpenAPI documentation for {app.get_title()}") + swagger.add_view('GET', 'swagger', klass=cls, + permission='common.api_swagger') + config.add_cornice_service(swagger) + + +def defaults(config, **kwargs): + base = globals() + + CommonView = kwargs.get('CommonView', base['CommonView']) + CommonView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/core.py b/tailbone/api/core.py new file mode 100644 index 00000000..0d8eec32 --- /dev/null +++ b/tailbone/api/core.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Core Views +""" + +from tailbone.views import View + + +def api(view_meth): + """ + Common decorator for all API views. Ideally this would not be needed..but + for now, alas, it is. + """ + def wrapped(view, *args, **kwargs): + + # TODO: why doesn't this work here...? (instead we have to repeat this + # code in lots of other places) + # if view.request.method == 'OPTIONS': + # return view.request.response + + # invoke the view logic first, since presumably it may involve a + # redirect in which case we don't really need to add the CSRF token. + # main known use case for this is the /logout endpoint - if that gets + # hit then the "current" (old) session will be destroyed, in which case + # we can't use the token from that, but instead must generate a new one. + result = view_meth(view, *args, **kwargs) + + # explicitly set CSRF token cookie, unless OPTIONS request + # TODO: why doesn't pyramid do this for us again? + if view.request.method != 'OPTIONS': + view.request.response.set_cookie(name='XSRF-TOKEN', + value=view.request.session.get_csrf_token()) + + return result + + return wrapped + + +class APIView(View): + """ + Base class for all API views. + """ + + def pretty_datetime(self, dt): + if not dt: + return "" + return dt.strftime('%Y-%m-%d @ %I:%M %p') + + def get_user_info(self, user): + """ + This method is present on *all* API views, and is meant to provide a + single means of obtaining "common" user info, for return to the caller. + Such info may be returned in several places, e.g. upon login but also + in the "check session" call, or e.g. as part of a broader return value + from any other call. + + :returns: Dictionary of user info data, ready for JSON serialization. + + Note that you should *not* (usually) override this method in any view, + but instead configure a "supplemental" function which can then add or + replace info entries. Config for that looks like e.g.: + + .. code-block:: ini + + [tailbone.api] + extra_user_info = poser.web.api.util:extra_user_info + + Note that the above config assumes a simple *function* defined in your + ``util`` module; such a function would look like e.g.:: + + def extra_user_info(request, user, **info): + # add favorite color + info['favorite_color'] = 'green' + # override display name + info['display_name'] = "TODO" + # remove short_name + info.pop('short_name', None) + return info + """ + app = self.get_rattail_app() + auth = app.get_auth_handler() + + # basic / default info + is_admin = auth.user_is_admin(user) + employee = app.get_employee(user) + info = { + 'uuid': user.uuid, + 'username': user.username, + 'display_name': user.display_name, + 'short_name': auth.get_short_display_name(user), + 'is_admin': is_admin, + 'is_root': is_admin and self.request.session.get('is_root', False), + 'employee_uuid': employee.uuid if employee else None, + 'email_address': app.get_contact_email_address(user), + } + + # maybe get/use "extra" info + extra = self.rattail_config.get('tailbone.api', 'extra_user_info', + usedb=False) + if extra: + extra = app.load_object(extra) + info = extra(self.request, user, **info) + + return info diff --git a/tailbone/forms/renderers/employees.py b/tailbone/api/customers.py similarity index 52% rename from tailbone/forms/renderers/employees.py rename to tailbone/api/customers.py index af5ce631..85d28c24 100644 --- a/tailbone/forms/renderers/employees.py +++ b/tailbone/api/customers.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. # @@ -21,29 +21,40 @@ # ################################################################################ """ -Employee Field Renderers +Tailbone Web API - Customer Views """ -from __future__ import unicode_literals, absolute_import +from rattail.db import model -import six -from webhelpers2.html import tags - -from tailbone.forms.renderers import AutocompleteFieldRenderer +from tailbone.api import APIMasterView -class EmployeeFieldRenderer(AutocompleteFieldRenderer): +class CustomerView(APIMasterView): """ - Renderer for :class:`rattail.db.model.Employee` instance fields. + API views for Customer data """ - service_route = 'employees.autocomplete' + model_class = model.Customer + collection_url_prefix = '/customers' + object_url_prefix = '/customer' + supports_autocomplete = True + autocomplete_fieldname = 'name' - def render_readonly(self, **kwargs): - employee = self.raw_value - if not employee: - return '' - render_name = kwargs.get('render_name', six.text_type) - title = render_name(employee) - if kwargs.get('hyperlink') and self.request.has_perm('employees.view'): - return tags.link_to(title, self.request.route_url('employees.view', uuid=employee.uuid)) - return title + def normalize(self, customer): + return { + 'uuid': customer.uuid, + '_str': str(customer), + 'id': customer.id, + 'number': customer.number, + 'name': customer.name, + } + + +def defaults(config, **kwargs): + base = globals() + + CustomerView = kwargs.get('CustomerView', base['CustomerView']) + CustomerView.defaults(config) + + +def includeme(config): + defaults(config) 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 new file mode 100644 index 00000000..551d6428 --- /dev/null +++ b/tailbone/api/master.py @@ -0,0 +1,618 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Master View +""" + +import json + +from rattail.db.util import get_fieldnames + +from cornice import resource, Service + +from tailbone.api import APIView +from tailbone.db import Session +from tailbone.util import SortColumn + + +class APIMasterView(APIView): + """ + Base class for data model REST API views. + """ + listable = True + creatable = True + viewable = True + editable = True + deletable = True + supports_autocomplete = False + supports_download = False + supports_rawbytes = False + + @property + def Session(self): + return Session + + @classmethod + def get_model_class(cls): + if hasattr(cls, 'model_class'): + return cls.model_class + raise NotImplementedError("must set `model_class` for {}".format(cls.__name__)) + + @classmethod + def get_normalized_model_name(cls): + if hasattr(cls, 'normalized_model_name'): + return cls.normalized_model_name + return cls.get_model_class().__name__.lower() + + @classmethod + def get_route_prefix(cls): + """ + Returns a prefix which (by default) applies to all routes provided by + this view class. + """ + prefix = getattr(cls, 'route_prefix', None) + if prefix: + return prefix + model_name = cls.get_normalized_model_name() + return '{}s'.format(model_name) + + @classmethod + def get_permission_prefix(cls): + """ + Returns a prefix which (by default) applies to all permissions + leveraged by this view class. + """ + prefix = getattr(cls, 'permission_prefix', None) + if prefix: + return prefix + return cls.get_route_prefix() + + @classmethod + def get_collection_url_prefix(cls): + """ + Returns a prefix which (by default) applies to all "collection" URLs + provided by this view class. + """ + prefix = getattr(cls, 'collection_url_prefix', None) + if prefix: + return prefix + return '/{}'.format(cls.get_route_prefix()) + + @classmethod + def get_object_url_prefix(cls): + """ + Returns a prefix which (by default) applies to all "object" URLs + provided by this view class. + """ + prefix = getattr(cls, 'object_url_prefix', None) + if prefix: + return prefix + return '/{}'.format(cls.get_route_prefix()) + + @classmethod + def get_object_key(cls): + if hasattr(cls, 'object_key'): + return cls.object_key + return cls.get_normalized_model_name() + + @classmethod + def get_collection_key(cls): + if hasattr(cls, 'collection_key'): + return cls.collection_key + return '{}s'.format(cls.get_object_key()) + + @classmethod + def establish_method(cls, method_name): + """ + Establish the given HTTP method for this Cornice Resource. + + Cornice will auto-register any class methods for a resource, if they + are named according to what it expects (i.e. 'get', 'collection_get' + etc.). Tailbone API tries to make things automagical for the sake of + e.g. Poser logic, but in this case if we predefine all of these methods + and then some subclass view wants to *not* allow one, it's not clear + how to "undefine" it per se. Or at least, the more straightforward + thing (I think) is to not define such a method in the first place, if + it was not wanted. + + Enter ``establish_method()``, which is what finally "defines" each + resource method according to what the subclass has declared via its + various attributes (:attr:`creatable`, :attr:`deletable` etc.). + + Note that you will not likely have any need to use this + ``establish_method()`` yourself! But we describe its purpose here, for + clarity. + """ + def method(self): + internal_method = getattr(self, '_{}'.format(method_name)) + return internal_method() + + setattr(cls, method_name, method) + + def make_filter_spec(self): + if not self.request.GET.has_key('filters'): + return [] + + filters = json.loads(self.request.GET.getone('filters')) + return filters + + def make_sort_spec(self): + + # we prefer a "native sort" + if self.request.GET.has_key('nativeSort'): + return json.loads(self.request.GET.getone('nativeSort')) + + # these params are based on 'vuetable-2' + # https://www.vuetable.com/guide/sorting.html#initial-sorting-order + if 'sort' in self.request.params: + sort = self.request.params['sort'] + sortkey, sortdir = sort.split('|') + if sortdir != 'desc': + sortdir = 'asc' + return [ + { + # 'model': self.model_class.__name__, + 'field': sortkey, + 'direction': sortdir, + }, + ] + + # these params are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + if 'orderBy' in self.request.params and 'ascending' in self.request.params: + sortcol = self.interpret_sortcol(self.request.params['orderBy']) + if sortcol: + spec = { + 'field': sortcol.field_name, + 'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc', + } + if sortcol.model_name: + spec['model'] = sortcol.model_name + return [spec] + + def interpret_sortcol(self, order_by): + """ + This must return a ``SortColumn`` object based on parsing of the given + ``order_by`` string, which is "raw" as received from the client. + + Please override as necessary, but in all cases you should invoke + :meth:`sortcol()` to obtain your return value. Default behavior + for this method is to simply do (only) that:: + + return self.sortcol(order_by) + + Note that you can also return ``None`` here, if the given ``order_by`` + string does not represent a valid sort. + """ + return self.sortcol(order_by) + + def sortcol(self, field_name, model_name=None): + """ + Return a simple ``SortColumn`` object which denotes the field and + optionally, the model, to be used when sorting. + """ + if not model_name: + model_name = self.model_class.__name__ + return SortColumn(field_name, model_name) + + def join_for_sort_spec(self, query, sort_spec): + """ + This should apply any joins needed on the given query, to accommodate + requested sorting as per ``sort_spec`` - which will be non-empty but + otherwise no claims are made regarding its contents. + + Please override as necessary, but in all cases you should return a + query, either untouched or else with join(s) applied. + """ + model_name = sort_spec[0].get('model') + return self.join_for_sort_model(query, model_name) + + def join_for_sort_model(self, query, model_name): + """ + This should apply any joins needed on the given query, to accommodate + requested sorting on a field associated with the given model. + + Please override as necessary, but in all cases you should return a + query, either untouched or else with join(s) applied. + """ + return query + + def make_pagination_spec(self): + + # these params are based on 'vuetable-2' + # https://github.com/ratiw/vuetable-2-tutorial/wiki/prerequisite#sample-api-endpoint + if 'page' in self.request.params and 'per_page' in self.request.params: + page = self.request.params['page'] + per_page = self.request.params['per_page'] + if page.isdigit() and per_page.isdigit(): + return int(page), int(per_page) + + # these params are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + if 'page' in self.request.params and 'limit' in self.request.params: + page = self.request.params['page'] + limit = self.request.params['limit'] + if page.isdigit() and limit.isdigit(): + return int(page), int(limit) + + def base_query(self): + cls = self.get_model_class() + query = self.Session.query(cls) + return query + + def get_fieldnames(self): + if not hasattr(self, '_fieldnames'): + self._fieldnames = get_fieldnames( + self.rattail_config, self.model_class, + columns=True, proxies=True, relations=False) + return self._fieldnames + + def normalize(self, obj): + data = {'_str': str(obj)} + + for field in self.get_fieldnames(): + data[field] = getattr(obj, field) + + return data + + def _collection_get(self): + from sa_filters import apply_filters, apply_sort, apply_pagination + + query = self.base_query() + context = {} + + # maybe filter query + filter_spec = self.make_filter_spec() + if filter_spec: + query = apply_filters(query, filter_spec) + + # maybe sort query + sort_spec = self.make_sort_spec() + if sort_spec: + query = self.join_for_sort_spec(query, sort_spec) + query = apply_sort(query, sort_spec) + + # maybe paginate query + pagination_spec = self.make_pagination_spec() + if pagination_spec: + number, size = pagination_spec + query, pagination = apply_pagination(query, page_number=number, page_size=size) + + # these properties are based on 'vuetable-2' + # https://www.vuetable.com/guide/pagination.html#how-the-pagination-component-works + context['total'] = pagination.total_results + context['per_page'] = pagination.page_size + context['current_page'] = pagination.page_number + context['last_page'] = pagination.num_pages + context['from'] = pagination.page_size * (pagination.page_number - 1) + 1 + to = pagination.page_size * (pagination.page_number - 1) + pagination.page_size + if to > pagination.total_results: + context['to'] = pagination.total_results + else: + context['to'] = to + + # these properties are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + context['count'] = pagination.total_results + + objects = [self.normalize(obj) for obj in query] + + # TODO: test this for ratbob! + context[self.get_collection_key()] = objects + + # these properties are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + context['data'] = objects + if 'count' not in context: + context['count'] = len(objects) + + return context + + def get_object(self, uuid=None): + if not uuid: + uuid = self.request.matchdict['uuid'] + + obj = self.Session.get(self.get_model_class(), uuid) + if obj: + return obj + + raise self.notfound() + + def _get(self, obj=None, uuid=None): + if not obj: + obj = self.get_object(uuid=uuid) + key = self.get_object_key() + normal = self.normalize(obj) + return {key: normal, 'data': normal} + + def _collection_post(self): + """ + Default method for actually processing a POST request for the + collection, aka. "create new object". + """ + # assume our data comes only from request JSON body + data = self.request.json_body + + # add instance to session, and return data for it + try: + obj = self.create_object(data) + except Exception as error: + return self.json_response({'error': str(error)}) + else: + self.Session.flush() + return self._get(obj) + + def create_object(self, data): + """ + Create a new object instance and populate it with the given data. + + Note that this method by default will only populate *simple* fields, so + you may need to subclass and override to add more complex field logic. + """ + # create new instance of model class + cls = self.get_model_class() + obj = cls() + + # "update" new object with given data + obj = self.update_object(obj, data) + + # that's all we can do here, subclass must override if more needed + self.Session.add(obj) + return obj + + def _post(self, uuid=None): + """ + Default method for actually processing a POST request for an object, + aka. "update existing object". + """ + if not uuid: + uuid = self.request.matchdict['uuid'] + obj = self.Session.get(self.get_model_class(), uuid) + if not obj: + raise self.notfound() + + # assume our data comes only from request JSON body + data = self.request.json_body + + # try to update data for object, returning error as necessary + obj = self.update_object(obj, data) + if isinstance(obj, dict) and 'error' in obj: + return {'error': obj['error']} + + # return data for object + self.Session.flush() + return self._get(obj) + + def update_object(self, obj, data): + """ + Update the given object instance with the given data. + + Note that this method by default will only update *simple* fields, so + you may need to subclass and override to add more complex field logic. + """ + # set values for simple fields only + for key, value in data.items(): + if hasattr(obj, key): + # TODO: what about datetime, decimal etc.? + setattr(obj, key, value) + + # that's all we can do here, subclass must override if more needed + return obj + + ############################## + # delete + ############################## + + def _delete(self): + """ + View to handle DELETE action for an existing record/object. + """ + obj = self.get_object() + self.delete_object(obj) + + def delete_object(self, obj): + """ + Delete the object, or mark it as deleted, or whatever you need to do. + """ + # flush immediately to force any pending integrity errors etc. + self.Session.delete(obj) + self.Session.flush() + + ############################## + # download + ############################## + + def download(self): + """ + GET view allowing for download of a single file, which is attached to a + given record. + """ + obj = self.get_object() + + filename = self.request.GET.get('filename', None) + if not filename: + raise self.notfound() + path = self.download_path(obj, filename) + + response = self.file_response(path) + return response + + def download_path(self, obj, filename): + """ + Should return absolute path on disk, for the given object and filename. + Result will be used to return a file response to client. + """ + raise NotImplementedError + + def rawbytes(self): + """ + GET view allowing for direct access to the raw bytes of a file, which + is attached to a given record. Basically the same as 'download' except + this does not come as an attachment. + """ + obj = self.get_object() + + # TODO: is this really needed? + # filename = self.request.GET.get('filename', None) + # if filename: + # path = self.download_path(obj, filename) + # return self.file_response(path, attachment=False) + + return self.rawbytes_response(obj) + + def rawbytes_response(self, obj): + raise NotImplementedError + + ############################## + # autocomplete + ############################## + + def autocomplete(self): + """ + View which accepts a single ``term`` param, and returns a list of + autocomplete results to match. + """ + term = self.request.params.get('term', '').strip() + term = self.prepare_autocomplete_term(term) + if not term: + return [] + + results = self.get_autocomplete_data(term) + return [{'label': self.autocomplete_display(x), + 'value': self.autocomplete_value(x)} + for x in results] + + @property + def autocomplete_fieldname(self): + raise NotImplementedError("You must define `autocomplete_fieldname` " + "attribute for API view class: {}".format( + self.__class__)) + + def autocomplete_display(self, obj): + return getattr(obj, self.autocomplete_fieldname) + + def autocomplete_value(self, obj): + return obj.uuid + + def get_autocomplete_data(self, term): + query = self.make_autocomplete_query(term) + return query.all() + + def make_autocomplete_query(self, term): + model_class = self.get_model_class() + query = self.Session.query(model_class) + query = self.filter_autocomplete_query(query) + + field = getattr(model_class, self.autocomplete_fieldname) + query = query.filter(field.ilike('%%%s%%' % term))\ + .order_by(field) + + return query + + def filter_autocomplete_query(self, query): + return query + + def prepare_autocomplete_term(self, term): + """ + If necessary, massage the incoming search term for use with the + autocomplete query. + """ + return term + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # first, the primary resource API + + # list/search + if cls.listable: + cls.establish_method('collection_get') + resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix)) + + # create + if cls.creatable: + cls.establish_method('collection_post') + if hasattr(cls, 'permission_to_create'): + permission = cls.permission_to_create + else: + permission = '{}.create'.format(permission_prefix) + resource.add_view(cls.collection_post, permission=permission) + + # view + if cls.viewable: + cls.establish_method('get') + resource.add_view(cls.get, permission='{}.view'.format(permission_prefix)) + + # edit + if cls.editable: + cls.establish_method('post') + resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix)) + + # delete + if cls.deletable: + cls.establish_method('delete') + resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix)) + + # register primary resource API via cornice + object_resource = resource.add_resource( + cls, + collection_path=collection_url_prefix, + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}'.format(object_url_prefix)) + config.add_cornice_resource(object_resource) + + # now for some more "custom" things, which are still somewhat generic + + # autocomplete + if cls.supports_autocomplete: + autocomplete = Service(name='{}.autocomplete'.format(route_prefix), + path='{}/autocomplete'.format(collection_url_prefix)) + autocomplete.add_view('GET', 'autocomplete', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(autocomplete) + + # download + if cls.supports_download: + download = Service(name='{}.download'.format(route_prefix), + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}/download'.format(object_url_prefix)) + download.add_view('GET', 'download', klass=cls, + permission='{}.download'.format(permission_prefix)) + config.add_cornice_service(download) + + # rawbytes + if cls.supports_rawbytes: + rawbytes = Service(name='{}.rawbytes'.format(route_prefix), + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}/rawbytes'.format(object_url_prefix)) + rawbytes.add_view('GET', 'rawbytes', klass=cls, + permission='{}.download'.format(permission_prefix)) + config.add_cornice_service(rawbytes) diff --git a/tailbone/api/master2.py b/tailbone/api/master2.py new file mode 100644 index 00000000..4a5abb3e --- /dev/null +++ b/tailbone/api/master2.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Master View (v2) +""" + +from __future__ import unicode_literals, absolute_import + +import warnings + +from tailbone.api import APIMasterView + + +class APIMasterView2(APIMasterView): + """ + Base class for data model REST API views. + """ + + def __init__(self, request, context=None): + warnings.warn("APIMasterView2 class is deprecated; please use " + "APIMasterView instead", + DeprecationWarning, stacklevel=2) + super(APIMasterView2, self).__init__(request, context=context) diff --git a/tailbone/forms/renderers/stores.py b/tailbone/api/people.py similarity index 53% rename from tailbone/forms/renderers/stores.py rename to tailbone/api/people.py index 332a91a6..f7c08dfa 100644 --- a/tailbone/forms/renderers/stores.py +++ b/tailbone/api/people.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. # @@ -21,29 +21,39 @@ # ################################################################################ """ -Store Field Renderers +Tailbone Web API - Person Views """ -from __future__ import unicode_literals, absolute_import +from rattail.db import model -from formalchemy.fields import SelectFieldRenderer -from webhelpers2.html import tags +from tailbone.api import APIMasterView -class StoreFieldRenderer(SelectFieldRenderer): +class PersonView(APIMasterView): """ - Renderer for :class:`rattail.db.model.Store` instance fields. + API views for Person data """ + model_class = model.Person + permission_prefix = 'people' + collection_url_prefix = '/people' + object_url_prefix = '/person' - def render(self, **kwargs): - kwargs.setdefault('auto-enhance', 'true') - return super(StoreFieldRenderer, self).render(**kwargs) + def normalize(self, person): + return { + 'uuid': person.uuid, + '_str': str(person), + 'first_name': person.first_name, + 'last_name': person.last_name, + 'display_name': person.display_name, + } - def render_readonly(self, **kwargs): - store = self.raw_value - if not store: - return "" - text = "({}) {}".format(store.id, store.name) - if kwargs.get('hyperlink', True): - return tags.link_to(text, self.request.route_url('stores.view', uuid=store.uuid)) - return text + +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 new file mode 100644 index 00000000..3f29ff54 --- /dev/null +++ b/tailbone/api/products.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/>. +# +################################################################################ +""" +Tailbone Web API - Product Views +""" + +import logging + +import sqlalchemy as sa +from sqlalchemy import orm + +from cornice import Service + +from rattail.db import model + +from tailbone.api import APIMasterView + + +log = logging.getLogger(__name__) + + +class ProductView(APIMasterView): + """ + API views for Product data + """ + model_class = model.Product + collection_url_prefix = '/products' + object_url_prefix = '/product' + supports_autocomplete = True + + def __init__(self, request, context=None): + super(ProductView, self).__init__(request, context=context) + app = self.get_rattail_app() + self.products_handler = app.get_products_handler() + + def normalize(self, product): + + # get what we can from handler + data = self.products_handler.normalize_product(product, fields=[ + 'brand_name', + 'full_description', + 'department_name', + 'unit_price_display', + 'sale_price', + 'sale_price_display', + 'sale_ends', + 'sale_ends_display', + 'tpr_price', + 'tpr_price_display', + 'tpr_ends', + 'tpr_ends_display', + 'current_price', + 'current_price_display', + 'current_ends', + 'current_ends_display', + 'vendor_name', + 'costs', + 'image_url', + ]) + + # but must supplement + cost = product.cost + data.update({ + 'upc': str(product.upc), + 'scancode': product.scancode, + 'item_id': product.item_id, + 'item_type': product.item_type, + 'status_code': product.status_code, + 'default_unit_cost': cost.unit_cost if cost else None, + 'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None, + }) + + return data + + def make_autocomplete_query(self, term): + query = self.Session.query(model.Product)\ + .outerjoin(model.Brand)\ + .filter(sa.or_( + model.Brand.name.ilike('%{}%'.format(term)), + model.Product.description.ilike('%{}%'.format(term)))) + + if not self.request.has_perm('products.view_deleted'): + query = query.filter(model.Product.deleted == False) + + query = query.order_by(model.Brand.name, + model.Product.description)\ + .options(orm.joinedload(model.Product.brand)) + return query + + def autocomplete_display(self, product): + return product.full_description + + def quick_lookup(self): + """ + View for handling "quick lookup" user input, for index page. + """ + data = self.request.GET + entry = data['entry'] + + product = self.products_handler.locate_product_for_entry(self.Session(), + entry) + if not product: + return {'error': "Product not found"} + + return {'ok': True, + 'product': self.normalize(product)} + + def label_profiles(self): + """ + Returns the set of label profiles available for use with + printing label for product. + """ + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + + profiles = [] + for profile in label_handler.get_label_profiles(self.Session()): + profiles.append({ + 'uuid': profile.uuid, + 'description': profile.description, + }) + + return {'label_profiles': profiles} + + def print_labels(self): + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + data = self.request.json_body + + uuid = data.get('label_profile_uuid') + profile = self.Session.get(model.LabelProfile, uuid) if uuid else None + if not profile: + return {'error': "Label profile not found"} + + uuid = data.get('product_uuid') + product = self.Session.get(model.Product, uuid) if uuid else None + if not product: + return {'error': "Product not found"} + + try: + quantity = int(data.get('quantity')) + except: + return {'error': "Quantity must be integer"} + + printer = label_handler.get_printer(profile) + if not printer: + return {'error': "Couldn't get printer from label profile"} + + try: + printer.print_labels([({'product': product}, quantity)]) + except Exception as error: + log.warning("error occurred while printing labels", exc_info=True) + return {'error': str(error)} + + return {'ok': True} + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._product_defaults(config) + + @classmethod + def _product_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + + # quick lookup + quick_lookup = Service(name='{}.quick_lookup'.format(route_prefix), + path='{}/quick-lookup'.format(collection_url_prefix)) + quick_lookup.add_view('GET', 'quick_lookup', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(quick_lookup) + + # label profiles + label_profiles = Service(name=f'{route_prefix}.label_profiles', + path=f'{collection_url_prefix}/label-profiles') + label_profiles.add_view('GET', 'label_profiles', klass=cls, + permission=f'{permission_prefix}.print_labels') + config.add_cornice_service(label_profiles) + + # print labels + print_labels = Service(name='{}.print_labels'.format(route_prefix), + path='{}/print-labels'.format(collection_url_prefix)) + print_labels.add_view('POST', 'print_labels', klass=cls, + permission='{}.print_labels'.format(permission_prefix)) + config.add_cornice_service(print_labels) + + +def defaults(config, **kwargs): + base = globals() + + ProductView = kwargs.get('ProductView', base['ProductView']) + ProductView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py new file mode 100644 index 00000000..467c8a0d --- /dev/null +++ b/tailbone/api/upgrades.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Upgrade Views +""" + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class UpgradeView(APIMasterView): + """ + REST API views for Upgrade model. + """ + model_class = model.Upgrade + collection_url_prefix = '/upgrades' + object_url_prefix = '/upgrades' + + def normalize(self, upgrade): + data = { + 'created': upgrade.created.isoformat(), + 'description': upgrade.description, + 'enabled': upgrade.enabled, + 'executed': upgrade.executed.isoformat() if upgrade.executed else None, + # 'executed_by': + } + if upgrade.status_code is None: + data['status_code'] = None + else: + data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code, + str(upgrade.status_code)) + return data + + +def defaults(config, **kwargs): + base = globals() + + UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) + UpgradeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/users.py b/tailbone/api/users.py new file mode 100644 index 00000000..a6bcad57 --- /dev/null +++ b/tailbone/api/users.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - User Views +""" + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class UserView(APIMasterView): + """ + API views for User data + """ + model_class = model.User + collection_url_prefix = '/users' + object_url_prefix = '/user' + + def normalize(self, user): + return { + 'uuid': user.uuid, + 'username': user.username, + 'person_display_name': (user.person.display_name or '') if user.person else '', + 'active': user.active, + } + + def interpret_sortcol(self, order_by): + if order_by == 'person_display_name': + return self.sortcol('Person', 'display_name') + return self.sortcol(order_by) + + def join_for_sort_model(self, query, model_name): + if model_name == 'Person': + query = query.outerjoin(model.Person) + return query + + def update_object(self, user, data): + # TODO: should ensure prevent_password_change is respected + return super(UserView, self).update_object(user, data) + + +def defaults(config, **kwargs): + base = globals() + + UserView = kwargs.get('UserView', base['UserView']) + UserView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py new file mode 100644 index 00000000..64311b1b --- /dev/null +++ b/tailbone/api/vendors.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/>. +# +################################################################################ +""" +Tailbone Web API - Vendor Views +""" + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class VendorView(APIMasterView): + + model_class = model.Vendor + collection_url_prefix = '/vendors' + object_url_prefix = '/vendor' + supports_autocomplete = True + autocomplete_fieldname = 'name' + + def normalize(self, vendor): + return { + 'uuid': vendor.uuid, + '_str': str(vendor), + 'id': vendor.id, + 'name': vendor.name, + } + + +def defaults(config, **kwargs): + base = globals() + + VendorView = kwargs.get('VendorView', base['VendorView']) + VendorView.defaults(config) + + +def includeme(config): + defaults(config) 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 352b67f6..d2d0c5ef 100644 --- a/tailbone/app.py +++ b/tailbone/app.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,25 +24,23 @@ Application Entry Point """ -from __future__ import unicode_literals, absolute_import - import os -import warnings -import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker, scoped_session + +from wuttjamaican.util import parse_list -import rattail.db from rattail.config import make_config from rattail.exceptions import ConfigurationError -from rattail.db.config import get_engines, configure_versioning -from rattail.db.types import GPCType -import formalchemy as fa 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): @@ -56,45 +54,48 @@ def make_rattail_config(settings): # available for web requests later path = settings.get('rattail.config') if not path or not os.path.exists(path): - path = settings.get('edbob.config') - if not path or not os.path.exists(path): - raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config " - "to the path of your config file. Lame, but necessary.") - warnings.warn("[app:main] setting 'edbob.config' is deprecated; " - "please use 'rattail.config' setting instead", - DeprecationWarning) + raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config " + "to the path of your config file. Lame, but necessary.") rattail_config = make_config(path) settings['rattail_config'] = rattail_config - rattail_config.configure_logging() - rattail_engines = settings.get('rattail_engines') - if not rattail_engines: + # nb. this is for compaibility with wuttaweb + settings['wutta_config'] = rattail_config - # Load all Rattail database engines from config, and store in settings - # dict. This is necessary e.g. in the case of a host server, to have - # access to its subordinate store servers. - rattail_engines = get_engines(rattail_config) - settings['rattail_engines'] = rattail_engines + # 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 the database session classes. Note that most of the time we'll - # be using the Tailbone Session, but occasionally (e.g. within batch - # processing threads) we want the Rattail Session. The reason is that - # during normal request processing, the Tailbone Session is preferable as - # it includes Zope Transaction magic. Within an explicitly-spawned thread - # however, this is *not* desirable. - rattail.db.Session.configure(bind=rattail_engines['default']) - tailbone.db.Session.configure(bind=rattail_engines['default']) - if hasattr(rattail_config, 'tempmon_engine'): - tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine) + # configure database sessions + 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': + Session = scoped_session(sessionmaker(bind=engine)) + register(Session) + tailbone.db.ExtraTrainwreckSessions[key] = Session # Make sure rattail config object uses our scoped session, to avoid # unnecessary connections (and pooling limits). rattail_config._session_factory = lambda: (tailbone.db.Session(), False) - # Configure (or not) Continuum versioning. - configure_versioning(rattail_config) return rattail_config @@ -104,7 +105,12 @@ def provide_postgresql_settings(settings): this enables retrying transactions a second time, in an attempt to gracefully handle database restarts. """ - settings.setdefault('tm.attempts', 2) + try: + import pyramid_retry + except ImportError: + settings.setdefault('tm.attempts', 2) + else: + settings.setdefault('retry.attempts', 2) class Root(dict): @@ -122,53 +128,197 @@ def make_pyramid_config(settings, configure_csrf=True): """ Make a Pyramid config object from the given settings. """ - from tailbone.forms.alchemy import TemplateEngine - from tailbone.forms import renderers + 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) - # configure user authorization / authentication - config.set_authorization_policy(TailboneAuthorizationPolicy()) - config.set_authentication_policy(SessionAuthenticationPolicy()) + # add rattail config directly to registry, for access throughout the app + config.registry['rattail_config'] = rattail_config - # always require CSRF token protection + # configure user authorization / authentication + config.set_security_policy(TailboneSecurityPolicy()) + + # maybe require CSRF token protection if configure_csrf: - config.set_default_csrf_options(require_csrf=True, token='_csrf') + config.set_default_csrf_options(require_csrf=True, + token=csrf_token_name(rattail_config), + header=csrf_header_name(rattail_config)) # Bring in some Pyramid goodies. config.include('tailbone.beaker') config.include('pyramid_deform') + config.include('pyramid_fanstatic') config.include('pyramid_mako') config.include('pyramid_tm') - # 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') + # 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) - # TODO: This can finally be removed once all CRUD/index views have been - # converted to use the new master view etc. - for label, perms in settings.get('edbob.permissions', []): - groupkey = label.lower().replace(' ', '_') - config.add_tailbone_permission_group(groupkey, label) - for key, label in perms: - config.add_tailbone_permission(groupkey, key, label) + # bring in the pyramid_retry logic, if available + # TODO: pretty soon we can require this package, hopefully.. + try: + import pyramid_retry + except ImportError: + pass + else: + config.include('pyramid_retry') - # Configure FormAlchemy. - fa.config.engine = TemplateEngine() - fa.FieldSet.default_renderers[sa.Boolean] = renderers.YesNoFieldRenderer - fa.FieldSet.default_renderers[sa.Date] = renderers.DateFieldRenderer - fa.FieldSet.default_renderers[sa.DateTime] = renderers.DateTimeFieldRenderer - fa.FieldSet.default_renderers[sa.Time] = renderers.TimeFieldRenderer - fa.FieldSet.default_renderers[GPCType] = renderers.GPCFieldRenderer + # 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'] + + theme = get_effective_theme(rattail_config) + settings['tailbone.theme'] = theme + + directories = settings['mako.directories'] + if isinstance(directories, str): + directories = parse_list(directories) + + path = get_theme_template_path(rattail_config) + directories.insert(0, path) + settings['mako.directories'] = directories + + def configure_postgresql(pyramid_config): """ Add some PostgreSQL-specific tweaks to the final app config. Specifically, @@ -182,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 9db292ad..95bf90ba 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.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,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,53 +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): - from rattail.db import model - from rattail.db.auth import has_permission + 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 - 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: - assert False # should no longer happen..right? - user = Session.query(model.User).get(userid) - if user: - if has_permission(Session(), user, permission): - return True - if Everyone in principals: - return has_permission(Session(), None, permission) - return False + def load_identity(self, request): + config = request.registry.settings.get('rattail_config') + app = config.get_app() + user = None - def principals_allowed_by_permission(self, context, permission): - raise NotImplementedError + 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() + user = auth.authenticate_user_token(self.db_session, token) -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) + if not user: + # fetch user uuid from current session + uuid = self.session_helper.authenticated_userid(request) + if not uuid: + 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) + # fetch user object from db + model = app.model + user = self.db_session.get(model.User, uuid) + if not user: + return + + # this user is responsible for data changes in current request + self.db_session.set_continuum_user(user) + return user 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 51293a26..8392ba0a 100644 --- a/tailbone/config.py +++ b/tailbone/config.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. # @@ -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: @@ -47,3 +48,31 @@ class ConfigExtension(BaseExtension): def configure(self, config): Session.configure(rattail_config=config) configure_session(config, Session) + + # provide default theme selection + config.setdefault('tailbone', 'themes.keys', 'default, butterball') + config.setdefault('tailbone', 'themes.expose_picker', 'true') + + # override oruga detection + config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga') + + +def csrf_token_name(config): + return config.get('tailbone', 'csrf_token_name', default='_csrf') + + +def csrf_header_name(config): + return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN') + + +def global_help_url(config): + return config.get('tailbone', 'global_help_url') + + +def protected_usernames(config): + return config.getlist('tailbone', 'protected_usernames') + + +def should_expose_websockets(config): + return config.getbool('tailbone', 'expose_websockets', + usedb=False, default=False) diff --git a/tailbone/db.py b/tailbone/db.py index 041f750e..8b37f399 100644 --- a/tailbone/db.py +++ b/tailbone/db.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. # @@ -21,11 +21,9 @@ # ################################################################################ """ -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 @@ -35,24 +33,37 @@ from rattail.db import SessionBase from rattail.db.continuum import versioning_manager -Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, rattail_record_changes=False, expire_on_commit=False)) +Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, expire_on_commit=False)) # not necessarily used, but here if you need it TempmonSession = scoped_session(sessionmaker()) TrainwreckSession = scoped_session(sessionmaker()) +# empty dict for now, this must populated on app startup (if needed) +ExtraTrainwreckSessions = {} + class TailboneSessionDataManager(datamanager.SessionDataManager): - """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 @@ -64,25 +75,42 @@ 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. """ - if datamanager._SESSION_STATE.get(id(session), None) is None: + # the upstream internals of this function has changed a little over time. + # unfortunately for us, that means we must include each variant here. + + if datamanager._SESSION_STATE.get(session, None) is None: if session.twophase: DataManager = datamanager.TwoPhaseSessionDataManager else: @@ -90,44 +118,74 @@ def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transacti 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, ) @@ -139,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 595dbfc9..2e582b15 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.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,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,43 @@ 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): + def __init__(self, old_data, new_data, columns=None, fields=None, enums=None, + render_field=None, render_value=None, nature='dirty', + monospace=False, extra_row_attrs=None): self.old_data = old_data self.new_data = new_data self.columns = columns or ["field name", "old value", "new value"] self.fields = fields or self.make_fields() + self.enums = enums or {} self._render_field = render_field or self.render_field_default self.render_value = render_value or self.render_value_default + self.nature = nature self.monospace = monospace + self.extra_row_attrs = extra_row_attrs def make_fields(self): return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower()) @@ -61,6 +89,32 @@ class Diff(object): context['diff'] = self return HTML.literal(render(template, context)) + def get_row_attrs(self, field): + """ + Returns a *rendered* set of extra attributes for the ``<tr>`` element + for the given field. May be an empty string, or a snippet of HTML + attribute syntax, e.g.: + + .. code-block:: none + + class="diff" foo="bar" + + If you wish to supply additional attributes, please define + :attr:`extra_row_attrs`, which can be either a static dict, or a + callable returning a dict. + """ + attrs = {} + if self.values_differ(field): + attrs['class'] = 'diff' + + if self.extra_row_attrs: + if callable(self.extra_row_attrs): + attrs.update(self.extra_row_attrs(field, attrs)) + else: + attrs.update(self.extra_row_attrs) + + return HTML.render_attrs(attrs) + def render_field(self, field): return self._render_field(field, self) @@ -77,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 new file mode 100644 index 00000000..3468562a --- /dev/null +++ b/tailbone/exceptions.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Exceptions +""" + +from rattail.exceptions import RattailError + + +class TailboneError(RattailError): + """ + Base class for all Tailbone exceptions. + """ + + +class TailboneJSONFieldError(TailboneError): + """ + Error raised when JSON serialization of a form field results in an error. + This is just a simple wrapper, to make the error message more helpful for + the developer. + """ + + def __init__(self, field, error): + self.field = field + self.error = error + + def __str__(self): + return ("Failed to serialize field '{}' as JSON! " + "Original error was: {}".format(self.field, self.error)) diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py index 9982a978..34b34a6c 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.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,19 +21,10 @@ # ################################################################################ """ -Forms +Forms Library """ -from __future__ import unicode_literals, absolute_import - -from formencode import Schema - -from .core import Form, Field, FieldSet, GenericFieldSet -from .simpleform import SimpleForm, FormRenderer -from .alchemy import AlchemyForm -from .fields import AssociationProxyField -from .renderers import * - -from . import fields -from . import renderers -from . import validators +# 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/alchemy.py b/tailbone/forms/alchemy.py deleted file mode 100644 index 161d549f..00000000 --- a/tailbone/forms/alchemy.py +++ /dev/null @@ -1,117 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -FormAlchemy Forms -""" - -from __future__ import unicode_literals, absolute_import - -from rattail.core import Object - -import formalchemy as fa -from pyramid.renderers import render -from webhelpers2.html import HTML, tags - -from tailbone.db import Session - - -class TemplateEngine(fa.templates.TemplateEngine): - """ - Mako template engine for FormAlchemy. - """ - - def render(self, template, prefix='/forms/', suffix='.mako', **kwargs): - template = ''.join((prefix, template, suffix)) - return render(template, kwargs) - - -class AlchemyForm(Object): - """ - Form to contain a :class:`formalchemy.FieldSet` instance. - """ - id = None - create_label = "Create" - update_label = "Save" - - allow_successive_creates = False - - def __init__(self, request, fieldset, session=None, csrf_field='_csrf', **kwargs): - super(AlchemyForm, self).__init__(**kwargs) - self.request = request - self.fieldset = fieldset - self.session = session - self.csrf_field = csrf_field - - def _get_readonly(self): - return self.fieldset.readonly - def _set_readonly(self, val): - self.fieldset.readonly = val - readonly = property(_get_readonly, _set_readonly) - - @property - def successive_create_label(self): - return "%s and continue" % self.create_label - - def csrf(self, name=None): - """ - NOTE: this method was copied from `pyramid_simpleform.FormRenderer` - - Returns the CSRF hidden input. Creates new CSRF token - if none has been assigned yet. - - The name of the hidden field is **_csrf** by default. - """ - name = name or self.csrf_field - - token = self.request.session.get_csrf_token() - if token is None: - token = self.request.session.new_csrf_token() - - return tags.hidden(name, value=token) - - def csrf_token(self, name=None): - """ - NOTE: this method was copied from `pyramid_simpleform.FormRenderer` - - Convenience function. Returns CSRF hidden tag inside hidden DIV. - """ - return HTML.tag("div", self.csrf(name), style="display:none;") - - def render(self, **kwargs): - kwargs['form'] = self - if self.readonly: - template = '/forms/form_readonly.mako' - else: - template = '/forms/form.mako' - return render(template, kwargs) - - def render_fields(self): - return self.fieldset.render() - - def save(self): - self.fieldset.sync() - self.session.flush() - - def validate(self): - self.fieldset.rebind(data=self.request.params) - return self.fieldset.validate() diff --git a/tailbone/forms/common.py b/tailbone/forms/common.py new file mode 100644 index 00000000..6183d17f --- /dev/null +++ b/tailbone/forms/common.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/>. +# +################################################################################ +""" +Common Forms +""" + +from rattail.db import model + +import colander + + +@colander.deferred +def validate_user(node, kw): + session = kw['session'] + def validate(node, value): + user = session.get(model.User, value) + if not user: + raise colander.Invalid(node, "User not found") + return user.uuid + return validate + + +class Feedback(colander.Schema): + """ + Form schema for user feedback. + """ + email_key = colander.SchemaNode(colander.String(), + missing=colander.null) + + referrer = colander.SchemaNode(colander.String()) + + user = colander.SchemaNode(colander.String(), + missing=colander.null, + validator=validate_user) + + user_name = colander.SchemaNode(colander.String(), + missing=colander.null) + + please_reply_to = colander.SchemaNode(colander.String(), + missing=colander.null) + + message = colander.SchemaNode(colander.String()) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index c2702fb7..4024557b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.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. # @@ -24,96 +24,1436 @@ Forms Core """ -from __future__ import unicode_literals, absolute_import +import hashlib +import json +import logging +import warnings +from collections import OrderedDict -from rattail.util import OrderedDict, prettify +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY +from wuttjamaican.util import UNSPECIFIED -import formalchemy -from formalchemy.helpers import content_tag +from rattail.util import pretty_boolean +from rattail.db.util import get_fieldnames + +import colander +import deform +from colanderalchemy import SQLAlchemySchemaNode +from colanderalchemy.schema import _creation_order +from deform import widget as dfwidget +from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render +from webhelpers2.html import tags, HTML + +from wuttaweb.util import FieldList, get_form_data, make_json_safe + +from tailbone.db import Session +from tailbone.util import raw_datetime, render_markdown +from tailbone.forms import types +from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, + JQueryDateWidget, JQueryTimeWidget, + FileUploadWidget, MultiFileUploadWidget) +from tailbone.exceptions import TailboneJSONFieldError + + +log = logging.getLogger(__name__) + + +def get_association_proxy(mapper, field): + """ + Returns the association proxy corresponding to the given field name if one + exists, or ``None``. + """ + try: + desc = getattr(mapper.all_orm_descriptors, field) + except AttributeError: + pass + else: + if desc.extension_type == ASSOCIATION_PROXY: + return desc + + +def get_association_proxy_target(inspector, field): + """ + Returns the property on the main class, which represents the "target" + for the given association proxy field name. Typically this will refer + to the "extension" model class. + """ + proxy = get_association_proxy(inspector, field) + if proxy: + proxy_target = inspector.get_property(proxy.target_collection) + if isinstance(proxy_target, orm.RelationshipProperty) and not proxy_target.uselist: + return proxy_target + + +def get_association_proxy_column(inspector, field): + """ + Returns the property on the proxy target class, for the column which is + reflected by the proxy. + """ + proxy_target = get_association_proxy_target(inspector, field) + if proxy_target: + if proxy_target.mapper.has_property(field): + prop = proxy_target.mapper.get_property(field) + if isinstance(prop, orm.ColumnProperty) and isinstance(prop.columns[0], sa.Column): + return prop + + +class CustomSchemaNode(SQLAlchemySchemaNode): + + def association_proxy(self, field): + """ + Returns the association proxy corresponding to the given field name if + one exists, or ``None``. + """ + return get_association_proxy(self.inspector, field) + + def association_proxy_target(self, field): + """ + Returns the property on the main class, which represents the "target" + for the given association proxy field name. Typically this will refer + to the "extension" model class. + """ + 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. + """ + return get_association_proxy_column(self.inspector, field) + + def supported_association_proxy(self, field): + """ + Returns boolean indicating whether the association proxy corresponding + to the given field name, is "supported" with typical logic. + """ + if not self.association_proxy_column(field): + return False + return True + + def add_nodes(self, includes, excludes, overrides): + """ + Add all automatic nodes to the schema. + + .. note:: + This method was copied from upstream and modified to add automatic + handling of "association proxy" fields. + """ + if set(excludes) & set(includes): + msg = 'excludes and includes are mutually exclusive.' + raise ValueError(msg) + + # sorted to maintain the order in which the attributes + # are defined + properties = sorted(self.inspector.attrs, key=_creation_order) + if excludes: + if includes: + raise ValueError("Must pass includes *or* excludes, but not both") + supported = [prop.key for prop in properties + if prop.key not in excludes] + elif includes: + supported = includes + elif includes is not None: + supported = [] + + for name in supported: + prop = self.inspector.attrs.get(name, name) + + if name in excludes or (includes and name not in includes): + log.debug('Attribute %s skipped imperatively', name) + continue + + name_overrides_copy = overrides.get(name, {}).copy() + + if (isinstance(prop, orm.ColumnProperty) + and isinstance(prop.columns[0], sa.Column)): + node = self.get_schema_from_column( + prop, + name_overrides_copy + ) + elif isinstance(prop, orm.RelationshipProperty): + if prop.mapper.class_ in self.parents_ and name not in includes: + continue + node = self.get_schema_from_relationship( + prop, + name_overrides_copy + ) + elif isinstance(prop, colander.SchemaNode): + node = prop + else: + + # magic for association proxy fields + column = self.association_proxy_column(name) + if column: + node = self.get_schema_from_column(column, name_overrides_copy) + + else: + log.debug( + 'Attribute %s skipped due to not being ' + 'a ColumnProperty or RelationshipProperty', + name + ) + continue + + if node is not None: + self.add(node) + + def get_schema_from_relationship(self, prop, overrides): + """ Build and return a :class:`colander.SchemaNode` for a relationship. + """ + + # for some reason ColanderAlchemy wants to crawl our entire ORM by + # default, by way of relationships. this 'excludes' hack is used to + # prevent that, by forcing skip of 2nd-level relationships + + excludes = [] + if isinstance(prop, orm.RelationshipProperty): + for next_prop in prop.mapper.iterate_properties: + + # don't include secondary relationships + if isinstance(next_prop, orm.RelationshipProperty): + excludes.append(next_prop.key) + + # don't include fields of binary type + elif isinstance(next_prop, orm.ColumnProperty): + for column in next_prop.columns: + if isinstance(column.type, sa.LargeBinary): + excludes.append(next_prop.key) + + if excludes: + overrides['excludes'] = excludes + + return super().get_schema_from_relationship(prop, overrides) + + def dictify(self, obj): + """ Return a dictified version of `obj` using schema information. + + .. note:: + This method was copied from upstream and modified to add automatic + handling of "association proxy" fields. + """ + dict_ = super().dictify(obj) + for node in self: + + name = node.name + if name not in dict_: + # we're only processing association proxy fields here + if not self.supported_association_proxy(name): + continue + + value = getattr(obj, name) + if value is None: + if isinstance(node.typ, colander.String): + # colander has an issue with `None` on a String type + # where it translates it into "None". Let's check + # for that specific case and turn it into a + # `colander.null`. + dict_[name] = colander.null + else: + # A specific case this helps is with Integer where + # `None` is an invalid value. We call serialize() + # to test if we have a value that will work later + # for serialization and then allow it if it doesn't + # raise an exception. Hopefully this also catches + # issues with user defined types and future issues. + try: + node.serialize(value) + except: + dict_[name] = colander.null + else: + dict_[name] = value + else: + dict_[name] = value + + return dict_ + + def objectify(self, dict_, context=None): + """ Return an object representing ``dict_`` using schema information. + + .. note:: + This method was copied from upstream and modified to add automatic + handling of "association proxy" fields. + """ + mapper = self.inspector + context = mapper.class_() if context is None else context + for attr in dict_: + if mapper.has_property(attr): + prop = mapper.get_property(attr) + if hasattr(prop, 'mapper'): + cls = prop.mapper.class_ + if prop.uselist: + # Sequence of objects + value = [self[attr].children[0].objectify(obj) + for obj in dict_[attr]] + else: + # Single object + value = self[attr].objectify(dict_[attr]) + else: + value = dict_[attr] + if value is colander.null: + # `colander.null` is never an appropriate + # value to be placed on an SQLAlchemy object + # so we translate it into `None`. + value = None + setattr(context, attr, value) + + else: + + # try to process association proxy field + if self.supported_association_proxy(attr): + value = dict_[attr] + if value is colander.null: + # `colander.null` is never an appropriate + # value to be placed on an SQLAlchemy object + # so we translate it into `None`. + value = None + setattr(context, attr, value) + + else: + # Ignore attributes if they are not mapped + log.debug( + 'SQLAlchemySchemaNode.objectify: %s not found on ' + '%s. This property has been ignored.', + attr, self + ) + continue + + return context class Form(object): """ Base class for all forms. """ - create_label = "Create" + save_label = "Submit" update_label = "Save" + show_cancel = True + auto_disable = True + auto_disable_save = True + auto_disable_cancel = True - def __init__(self, request, readonly=False, action_url=None): + 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, renderer_kwargs={}, + hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, + action_url=None, cancel_url=None, + vue_tagname=None, + vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={}, + # 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 []) + self.model_instance = model_instance + self.model_class = model_class + if self.model_instance and not self.model_class and not isinstance(self.model_instance, dict): + self.model_class = type(self.model_instance) + if self.model_class and self.fields is None: + self.set_fields(self.make_fields()) + self.appstruct = appstruct + self.nodes = nodes or {} + self.enums = enums or {} + self.labels = labels or {} + self.assume_local_times = assume_local_times + if renderers is None and self.model_class: + 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 + + # 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): + """ + 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 + + def set_fields(self, fields): + self.fields = FieldList(fields) + + def make_fields(self): + """ + Return a default list of fields, based on :attr:`model_class`. + """ + if not self.model_class: + raise ValueError("Must define model_class to use make_fields()") + + return get_fieldnames(self.request.rattail_config, self.model_class, + columns=True, proxies=True, relations=True) + + def set_grouping(self, items): + self.grouping = OrderedDict(items) + + def make_renderers(self): + """ + Return a default set of field renderers, based on :attr:`model_class`. + """ + if not self.model_class: + raise ValueError("Must define model_class to use make_renderers()") + + inspector = sa.inspect(self.model_class) + renderers = {} + + # TODO: clearly this should be leaner... + + # first look at regular column fields + for prop in inspector.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + if len(prop.columns) == 1: + column = prop.columns[0] + if isinstance(column.type, sa.DateTime): + if self.assume_local_times: + renderers[prop.key] = self.render_datetime_local + else: + renderers[prop.key] = self.render_datetime + elif isinstance(column.type, sa.Boolean): + renderers[prop.key] = self.render_boolean + + # then look at association proxy fields + for key, desc in inspector.all_orm_descriptors.items(): + if desc.extension_type == ASSOCIATION_PROXY: + prop = get_association_proxy_column(inspector, key) + if prop: + column = prop.columns[0] + if isinstance(column.type, sa.DateTime): + renderers[key] = self.render_datetime + elif isinstance(column.type, sa.Boolean): + renderers[key] = self.render_boolean + + return renderers + + 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) + + def insert_after(self, field, newfield): + self.fields.insert_after(field, newfield) + + def replace(self, field, newfield): + self.insert_after(field, newfield) + self.remove(field) + + def remove(self, *args): + for arg in args: + if arg in self.fields: + self.fields.remove(arg) + + # TODO: deprecare / remove this + def remove_field(self, key): + self.remove(key) + + # TODO: deprecare / remove this + def remove_fields(self, *args): + self.remove(*args) + + def make_schema(self): + if not self.schema: + + if not self.model_class: + # TODO + raise NotImplementedError + + mapper = orm.class_mapper(self.model_class) + + # first filter our "full" field list so we ignore certain ones. in + # particular we don't want readonly fields in the schema, or any + # which appear to be "private" + includes = [f for f in self.fields + if f not in self.readonly_fields + and not f.startswith('_') + and f != 'versions'] + + # derive list of "auto included" fields. this is all "included" + # fields which are part of the SQLAlchemy ORM for the object + auto_includes = [] + property_keys = [p.key for p in mapper.iterate_properties] + inspector = sa.inspect(self.model_class) + for field in includes: + if field in self.nodes: + continue # these are explicitly set; no magic wanted + if field in property_keys: + auto_includes.append(field) + elif get_association_proxy(inspector, field): + auto_includes.append(field) + + # make schema - only include *property* fields at this point + schema = CustomSchemaNode(self.model_class, includes=auto_includes) + + # for now, must manually add any "extra" fields? this includes all + # association proxy fields, not sure how other fields will behave + for field in includes: + if field not in schema: + node = self.nodes.get(field) + if not node: + node = colander.SchemaNode(colander.String(), name=field, missing='') + if not node.name: + node.name = field + schema.add(node) + + # apply any label overrides + for key, label in self.labels.items(): + if key in schema: + schema[key].title = label + + # apply any widget overrides + for key, widget in self.widgets.items(): + if key in schema: + schema[key].widget = widget + + # TODO: we are now doing this when making deform.Form, in which + # case, do we still need to do it here? + # apply any default values + for key, default in self.defaults.items(): + if key in schema: + schema[key].default = default + + # apply any validators + for key, validator in self.validators.items(): + if key is None: + # this one is form-wide + schema.validator = validator + elif key in schema: + schema[key].validator = validator + + # apply required flags + for key, required in self.required.items(): + if key in schema: + if required: + schema[key].missing = colander.required + else: + schema[key].missing = None # TODO? + + self.schema = schema + + return self.schema + + def set_label(self, key, label): + self.labels[key] = label + + # update schema if necessary + if self.schema and key in self.schema: + self.schema[key].title = label + + def get_label(self, key): + config = self.request.rattail_config + app = config.get_app() + return self.labels.get(key, app.make_title(key)) + + def set_readonly(self, key, readonly=True): + if readonly: + self.readonly_fields.add(key) + else: + if key in self.readonly_fields: + self.readonly_fields.remove(key) + + def set_node(self, key, nodeinfo, **kwargs): + if isinstance(nodeinfo, colander.SchemaNode): + node = nodeinfo + else: + kwargs.setdefault('name', key) + 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': + self.set_widget(key, PlainDateWidget()) + elif type_ == 'date_jquery': + # 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': + self.set_renderer(key, self.render_boolean) + self.set_widget(key, dfwidget.CheckboxWidget()) + elif type_ == 'currency': + self.set_renderer(key, self.render_currency) + elif type_ == 'quantity': + self.set_renderer(key, self.render_quantity) + elif type_ == 'percent': + self.set_renderer(key, self.render_percent) + elif type_ == 'gpc': + self.set_renderer(key, self.render_gpc) + elif type_ == 'enum': + self.set_renderer(key, self.render_enum) + elif type_ == 'codeblock': + self.set_renderer(key, self.render_codeblock) + self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + elif type_ == 'text': + self.set_renderer(key, self.render_pre_sans_serif) + self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + elif type_ == 'text_wrapped': + self.set_renderer(key, self.render_pre_sans_serif_wrapped) + self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + elif type_ == 'file': + tmpstore = SessionFileUploadTempStore(self.request) + 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)) + 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 + self.set_type(key, 'enum') + values = list(enum.items()) + if empty: + values.insert(0, empty) + self.set_widget(key, dfwidget.SelectWidget(values=values)) + else: + self.enums.pop(key, None) + + def get_enum(self, key): + return self.enums.get(key) + + # TODO: i don't think this is actually being used anywhere..? + def set_enum_value(self, key, enum_key, enum_value): + enum = self.enums.get(key) + if enum: + enum[enum_key] = enum_value + + def set_renderer(self, key, renderer): + if renderer is None: + if key in self.renderers: + del self.renderers[key] + 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 + + def set_widget(self, key, widget): + self.widgets[key] = widget + + # update schema if necessary + if self.schema and key in self.schema: + 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. + """ + self.required[key] = required + + def set_default(self, key, value): + """ + Set the default value for a given field. + """ + self.defaults[key] = value + + def set_helptext(self, key, value, dynamic=False): + """ + Set the help text for a given field. + """ + # nb. must avoid newlines, they cause some weird "blank page" error?! + self.helptext[key] = value.replace('\n', ' ') + if value and dynamic: + self.dynamic_helptext[key] = True + else: + self.dynamic_helptext.pop(key, None) + + def has_helptext(self, key): + """ + Returns boolean indicating whether the given field has accompanying + help text. + """ + return key in self.helptext + + def render_helptext(self, key): + """ + Render the help text for the given field. + """ + text = self.helptext[key] + text = text.replace('"', '"') + return HTML.literal(text) + + def set_vuejs_field_converter(self, field, converter): + self.vuejs_field_converters[field] = converter def render(self, **kwargs): - kwargs.setdefault('form', self) - if self.readonly: - template = '/forms/form_readonly.mako' - else: - template = '/forms/form.mako' - return render(template, kwargs) + warnings.warn("Form.render() is deprecated (for now?); " + "please use Form.render_deform() instead", + DeprecationWarning, stacklevel=2) + return self.render_deform(**kwargs) - def render_fields(self, **kwargs): - kwargs.setdefault('fieldset', self.fieldset) - if self.readonly: - template = '/forms/fieldset_readonly.mako' - else: - template = '/forms/fieldset.mako' - return render(template, kwargs) + def get_deform(self): + """ """ + return self.make_deform_form() + def make_deform_form(self): + if not hasattr(self, 'deform_form'): -class Field(object): - """ - Manually create instances of this class to populate a simple form. - """ + schema = self.make_schema() - def __init__(self, name, value=None, label=None, requires_label=True): - self.name = name - self.value = value - self._label = label or prettify(self.name) - self.requires_label = requires_label + # TODO: we are still also doing this when making the schema, but + # seems like this should be the right place instead? + # apply any default values + for key, default in self.defaults.items(): + if key in schema: + schema[key].default = default - def is_required(self): + # get initial form values from model instance + kwargs = {} + # TODO: ugh, this is necessary to avoid some logic + # which assumes a ColanderAlchemy schema i think? + if self.appstruct is not UNSPECIFIED: + if self.appstruct: + kwargs['appstruct'] = self.appstruct + elif self.model_instance: + if self.model_class: + kwargs['appstruct'] = schema.dictify(self.model_instance) + else: + kwargs['appstruct'] = self.model_instance + + # create form + form = deform.Form(schema, **kwargs) + form.tailbone_form = self + + # set readonly widget where applicable + for field in self.readonly_fields: + if field in form: + form[field].widget = ReadonlyWidget() + + self.deform_form = form + + return self.deform_form + + def render_vue_template(self, template='/forms/deform.mako', **context): + """ """ + output = self.render_deform(template=template, **context) + return HTML.literal(output) + + def render_deform(self, dform=None, template=None, **kwargs): + if not template: + template = '/forms/deform.mako' + + if dform is None: + dform = self.make_deform_form() + + # TODO: would perhaps be nice to leverage deform's default rendering + # someday..? i.e. using Chameleon *.pt templates + # return dform.render() + + 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: + 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 + context['readonly_fields'] = self.readonly_fields + 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 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 '[]' + + try: + 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("'", ''') + return dump + + def field_visible(self, field): + if self.hidden and self.hidden.get(field): + return False return True - def label(self): - return self._label + def set_vuejs_component_kwargs(self, **kwargs): + self.vuejs_component_kwargs.update(kwargs) - def label_tag(self, **html_options): + def render_vue_tag(self, **kwargs): + """ """ + return self.render_vuejs_component(**kwargs) + + def render_vuejs_component(self, **kwargs): """ - Logic stolen from FormAlchemy so all fields can render their own label. - Original docstring follows. + Render the Vue.js component HTML for the form. - return the <label /> tag for the field. + Most typically this is something like: + + .. code-block:: html + + <tailbone-form :configure-fields-help="configureFieldsHelp"> + </tailbone-form> """ - html_options.update(for_=self.name) - if 'class_' in html_options: - html_options['class_'] += self.is_required() and ' field_req' or ' field_opt' - else: - html_options['class_'] = self.is_required() and 'field_req' or 'field_opt' - return content_tag('label', self.label(), **html_options) + 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 render_readonly(self): - if self.value is None: + 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. + + Note that this method will generate the wrapper div and label, as well + as the field value. + """ + if field_name not in self.fields: return '' - return unicode(self.value) + + label = kwargs.get('label') + if not label: + label = self.get_label(field_name) + + value = self.render_field_value(field_name) or '' + + 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 + if self.renderers and field_name in self.renderers: + return self.renderers[field_name](record, field_name) + return self.render_generic(record, field_name) + + def render_generic(self, record, field_name): + value = self.obtain_value(record, field_name) + if value is None: + return "" + return str(value) + + def render_datetime(self, record, field_name): + value = self.obtain_value(record, field_name) + if value is None: + return "" + return raw_datetime(self.request.rattail_config, value) + + def render_datetime_local(self, record, field_name): + value = self.obtain_value(record, field_name) + if value is None: + return "" + 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): + seconds = self.obtain_value(record, field_name) + if seconds is None: + return "" + 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) + return pretty_boolean(value) + + def render_currency(self, record, field_name): + value = self.obtain_value(record, field_name) + if value is None: + return "" + try: + if value < 0: + return "(${:0,.2f})".format(0 - value) + return "${:0,.2f}".format(value) + except ValueError: + return str(value) + + def render_quantity(self, obj, field): + value = self.obtain_value(obj, field) + if value is None: + return "" + app = self.request.rattail_config.get_app() + return app.render_quantity(value) + + def render_percent(self, obj, field): + app = self.request.rattail_config.get_app() + value = self.obtain_value(obj, field) + return app.render_percent(value, places=3) + + def render_gpc(self, obj, field): + value = self.obtain_value(obj, field) + if value is None: + return "" + return value.pretty() + + def render_enum(self, record, field_name): + value = self.obtain_value(record, field_name) + if value is None: + return "" + enum = self.enums.get(field_name) + if enum and value in enum: + return str(enum[value]) + return str(value) + + def render_codeblock(self, record, field_name): + value = self.obtain_value(record, field_name) + if value is None: + return "" + return HTML.tag('pre', value) + + def render_pre_sans_serif(self, record, field_name, wrapped=False): + value = self.obtain_value(record, field_name) + if value is None: + return "" + + kwargs = { + 'c': value, + # this uses a Bulma helper class, for which we also add + # custom styles to our "default" base.css (for jquery + # theme) + 'class_': 'is-family-sans-serif', + } + + if wrapped: + kwargs['style'] = 'white-space: pre-wrap;' + + return HTML.tag('pre', **kwargs) + + def render_pre_sans_serif_wrapped(self, record, field_name): + return self.render_pre_sans_serif(record, field_name, wrapped=True) + + 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: + 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): + """ + Try to validate the form. + + This should work whether data was submitted as classic POST + data, or as JSON body. + + :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) + + if hasattr(self, 'validated'): + del self.validated + if self.request.method != 'POST': + return False + + controls = get_form_data(self.request).items() + + # unfortunately the normal form logic (i.e. peppercorn) is + # expecting all values to be strings, whereas if our data + # came from JSON body, may have given us some Pythonic + # objects. so here we must convert them *back* to strings + # TODO: this seems like a hack, i must be missing something + # TODO: also this uses same "JSON" check as get_form_data() + if self.request.is_xhr and not self.request.POST: + controls = [[key, val] for key, val in controls] + for i in range(len(controls)): + key, value = controls[i] + if value is None: + controls[i][1] = '' + elif value is True: + controls[i][1] = 'true' + elif value is False: + controls[i][1] = 'false' + elif not isinstance(value, str): + controls[i][1] = str(value) + + dform = self.make_deform_form() + try: + self.validated = dform.validate(controls) + return True + except deform.ValidationFailure: + return False -class FieldSet(object): - """ - Generic fieldset for use with manually-created simple forms. +@colander.deferred +def upload_widget(node, kw): + request = kw['request'] + tmpstore = SessionFileUploadTempStore(request) + return dfwidget.FileUploadWidget(tmpstore) + + +class SimpleFileImport(colander.Schema): """ + Schema for simple file import. Note that you must bind your ``request`` + object to this schema, i.e.:: - def __init__(self): - self.fields = OrderedDict() - self.render_fields = self.fields - - -class GenericFieldSet(formalchemy.FieldSet): + schema = SimpleFileImport().bind(request=request) """ - FieldSet class based on FormAlchemy, but without the SQLAlchemy magic. - """ - __sa__ = False - _bound_pk = None - data = None - prettify = staticmethod(prettify) + filename = colander.SchemaNode(deform.FileData(), + widget=upload_widget) diff --git a/tailbone/forms/fields.py b/tailbone/forms/fields.py deleted file mode 100644 index 8f401bbd..00000000 --- a/tailbone/forms/fields.py +++ /dev/null @@ -1,106 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -FormAlchemy Fields -""" - -from __future__ import unicode_literals, absolute_import - -import formalchemy as fa - - -def AssociationProxyField(name, **kwargs): - """ - Returns a FormAlchemy ``Field`` class which is aware of association - proxies. - """ - - class ProxyField(fa.Field): - - def sync(self): - if not self.is_readonly(): - setattr(self.parent.model, self.name, - self.renderer.deserialize()) - - def value(obj): - return getattr(obj, name, None) - - kwargs.setdefault('value', value) - return ProxyField(name, **kwargs) - - -class DefaultEmailField(fa.Field): - """ - Generic field for view/edit of default email address for a contact - """ - - def __init__(self, name=None, **kwargs): - kwargs.setdefault('value', self.value) - if 'renderer' not in kwargs: - from tailbone.forms.renderers import EmailFieldRenderer - kwargs['renderer'] = EmailFieldRenderer - super(DefaultEmailField, self).__init__(name, **kwargs) - - def value(self, contact): - if contact.emails: - return contact.emails[0].address - - def sync(self): - if not self.is_readonly(): - address = self._deserialize() - contact = self.parent.model - if contact.emails: - if address: - email = contact.emails[0] - email.address = address - else: - contact.emails.pop(0) - elif address: - email = contact.add_email_address(address) - - -class DefaultPhoneField(fa.Field): - """ - Generic field for view/edit of default phone number for a contact - """ - - def __init__(self, name=None, **kwargs): - kwargs.setdefault('value', self.value) - super(DefaultPhoneField, self).__init__(name, **kwargs) - - def value(self, contact): - if contact.phones: - return contact.phones[0].number - - def sync(self): - if not self.is_readonly(): - number = self._deserialize() - contact = self.parent.model - if contact.phones: - if number: - phone = contact.phones[0] - phone.number = number - else: - contact.phones.pop(0) - elif number: - phone = contact.add_phone_number(number) diff --git a/tailbone/forms/receiving.py b/tailbone/forms/receiving.py new file mode 100644 index 00000000..9f5706c7 --- /dev/null +++ b/tailbone/forms/receiving.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Forms for Receiving +""" + +from rattail.db import model + +import colander + + +@colander.deferred +def valid_purchase_batch_row(node, kw): + session = kw['session'] + def validate(node, value): + row = session.get(model.PurchaseBatchRow, value) + if not row: + raise colander.Invalid(node, "Batch row not found") + if row.batch.executed: + raise colander.Invalid(node, "Batch has already been executed") + return row.uuid + return validate + + +class ReceiveRow(colander.MappingSchema): + + row = colander.SchemaNode(colander.String(), + validator=valid_purchase_batch_row) + + mode = colander.SchemaNode(colander.String(), + validator=colander.OneOf([ + 'received', + 'damaged', + 'expired', + 'missing', + # 'mispick', + ])) + + cases = colander.SchemaNode(colander.Decimal(), + missing=colander.null) + + units = colander.SchemaNode(colander.Decimal(), + missing=colander.null) + + expiration_date = colander.SchemaNode(colander.Date(), + missing=colander.null) + + quick_receive = colander.SchemaNode(colander.Boolean()) diff --git a/tailbone/forms/renderers/__init__.py b/tailbone/forms/renderers/__init__.py deleted file mode 100644 index b0f30233..00000000 --- a/tailbone/forms/renderers/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -FormAlchemy Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -from .core import CustomFieldRenderer, DateFieldRenderer - -from .common import (StrippedTextFieldRenderer, CodeTextAreaFieldRenderer, AutocompleteFieldRenderer, - DecimalFieldRenderer, CurrencyFieldRenderer, QuantityFieldRenderer, - DateTimeFieldRenderer, DateTimePrettyFieldRenderer, LocalDateTimeFieldRenderer, TimeFieldRenderer, - EmailFieldRenderer, EnumFieldRenderer, YesNoFieldRenderer) - -from .files import FileFieldRenderer - -from .people import PersonFieldRenderer, PeopleFieldRenderer, CustomerFieldRenderer -from .users import UserFieldRenderer, PermissionsFieldRenderer -from .employees import EmployeeFieldRenderer - -from .stores import StoreFieldRenderer -from .vendors import VendorFieldRenderer, PurchaseFieldRenderer -from .products import (GPCFieldRenderer, ScancodeFieldRenderer, - DepartmentFieldRenderer, SubdepartmentFieldRenderer, CategoryFieldRenderer, - BrandFieldRenderer, ProductFieldRenderer, - CostFieldRenderer, PriceFieldRenderer, PriceWithExpirationFieldRenderer) - -from .custorders import CustomerOrderFieldRenderer - -from .batch import BatchIDFieldRenderer, HandheldBatchFieldRenderer, HandheldBatchesFieldRenderer diff --git a/tailbone/forms/renderers/batch.py b/tailbone/forms/renderers/batch.py deleted file mode 100644 index d45a17ac..00000000 --- a/tailbone/forms/renderers/batch.py +++ /dev/null @@ -1,103 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -Batch Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -import os -import stat -import random - -import formalchemy as fa -from webhelpers2.html import tags, HTML - -from tailbone.forms.renderers import FileFieldRenderer as BaseFileFieldRenderer - - -class BatchIDFieldRenderer(fa.FieldRenderer): - """ - Renderer for batch ID fields. - """ - def render_readonly(self, **kwargs): - try: - batch_id = self.raw_value - except AttributeError: - # this can happen when creating a new batch, b/c the default value - # comes from a sequence - pass - else: - if batch_id: - return '{:08d}'.format(batch_id) - return '' - - -class FileFieldRenderer(BaseFileFieldRenderer): - """ - Custom file field renderer for batches based on a single source data file. - In edit mode, shows a file upload field. In readonly mode, shows the - filename and its size. - """ - - def get_size(self): - size = super(FileFieldRenderer, self).get_size() - if size: - return size - batch = self.field.parent.model - path = batch.filepath(self.request.rattail_config, filename=self.field.value) - if os.path.isfile(path): - return os.stat(path)[stat.ST_SIZE] - return 0 - - def render(self, **kwargs): - return BaseFileFieldRenderer.render(self, **kwargs) - - -class HandheldBatchFieldRenderer(fa.FieldRenderer): - """ - Renderer for inventory batch's "handheld batch" field. - """ - - def render_readonly(self, **kwargs): - batch = self.raw_value - if batch: - return tags.link_to( - batch.id_str, - self.request.route_url('batch.handheld.view', uuid=batch.uuid)) - return '' - - -class HandheldBatchesFieldRenderer(fa.FieldRenderer): - """ - Renders a list of associated handheld batches, for a given (presumably - inventory or labels) batch. - """ - - def render_readonly(self, **kwargs): - items = '' - for handheld in self.raw_value: - text = tags.link_to(handheld.handheld.id_str, - self.request.route_url('batch.handheld.view', uuid=handheld.handheld_uuid)) - items += HTML.tag('li', c=text) - return HTML.tag('ul', c=items) diff --git a/tailbone/forms/renderers/bouncer.py b/tailbone/forms/renderers/bouncer.py deleted file mode 100644 index 019abab7..00000000 --- a/tailbone/forms/renderers/bouncer.py +++ /dev/null @@ -1,63 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -Batch Field Renderers -""" - -from __future__ import unicode_literals - -import os -import stat -import random - -from formalchemy.ext import fsblob - - -class BounceMessageFieldRenderer(fsblob.FileFieldRenderer): - """ - Custom file field renderer for email bounce messages. In readonly mode, - shows the filename and size. - """ - - @classmethod - def new(cls, request, handler): - name = 'Configured%s_%s' % (cls.__name__, unicode(random.random())[2:]) - return type(str(name), (cls,), dict(request=request, handler=handler)) - - @property - def storage_path(self): - return self.handler.root_msgdir - - def get_size(self): - size = super(BounceMessageFieldRenderer, self).get_size() - if size: - return size - bounce = self.field.parent.model - path = os.path.join(self.handler.msgpath(bounce)) - if os.path.isfile(path): - return os.stat(path)[stat.ST_SIZE] - return 0 - - def get_url(self, filename): - bounce = self.field.parent.model - return self.request.route_url('emailbounces.download', uuid=bounce.uuid) diff --git a/tailbone/forms/renderers/common.py b/tailbone/forms/renderers/common.py deleted file mode 100644 index e1468e08..00000000 --- a/tailbone/forms/renderers/common.py +++ /dev/null @@ -1,315 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Common Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -import datetime - -from rattail.time import localtime, make_utc -from rattail.util import pretty_quantity - -import formalchemy as fa -from formalchemy import fields as fa_fields, helpers as fa_helpers -from pyramid.renderers import render -from webhelpers2.html import HTML, tags - -from tailbone.util import pretty_datetime, raw_datetime - - -class StrippedTextFieldRenderer(fa.TextFieldRenderer): - """ - Standard text field renderer, which strips whitespace from either end of - the input value on deserialization. - """ - - def deserialize(self): - value = super(StrippedTextFieldRenderer, self).deserialize() - if value is not None: - return value.strip() - - -class CodeTextAreaFieldRenderer(fa.TextAreaFieldRenderer): - - def render_readonly(self, **kwargs): - value = self.raw_value - if not value: - return '' - return HTML.tag('pre', c=value) - - def render(self, **kwargs): - kwargs.setdefault('size', (80, 8)) - return super(CodeTextAreaFieldRenderer, self).render(**kwargs) - - -class AutocompleteFieldRenderer(fa.FieldRenderer): - """ - Custom renderer for an autocomplete field. - """ - - service_route = None - width = '300px' - - @property - def focus_name(self): - return self.name + '-textbox' - - @property - def needs_focus(self): - return not bool(self.value or self.field_value) - - @property - def field_display(self): - return self.raw_value - - @property - def field_value(self): - return self.value - - @property - def service_url(self): - return self.request.route_url(self.service_route) - - def render(self, options=None, **kwargs): - if kwargs.pop('autocomplete', True): - return self.render_autocomplete(**kwargs) - # 'selected' is a kwarg for autocomplete template *and* select tag - kwargs.pop('selected', None) - return self.render_dropdown(options, **kwargs) - - def render_autocomplete(self, **kwargs): - kwargs.setdefault('field_name', self.name) - kwargs.setdefault('field_value', self.field_value) - kwargs.setdefault('field_display', self.field_display) - kwargs.setdefault('service_url', self.service_url) - kwargs.setdefault('width', self.width) - return render('/forms/field_autocomplete.mako', kwargs) - - def render_dropdown(self, options, **kwargs): - # NOTE: this logic copied from formalchemy.fields.SelectFieldRenderer.render() - kwargs.setdefault('auto-enhance', 'true') - if callable(options): - L = fa_fields._normalized_options(options(self.field.parent)) - if not self.field.is_required() and not self.field.is_collection: - L.insert(0, self.field._null_option) - else: - L = list(options) - if len(L) > 0: - if len(L[0]) == 2: - L = [(k, self.stringify_value(v)) for k, v in L] - else: - L = [fa_fields._stringify(k) for k in L] - return fa_fields.h.select(self.name, self.value, L, **kwargs) - - def render_readonly(self, **kwargs): - value = self.field_display - if value is None: - return u'' - return unicode(value) - - -class DateTimeFieldRenderer(fa.DateTimeFieldRenderer): - """ - This renderer assumes the datetime field value is in UTC, and will convert - it to the local time zone before rendering it in the standard "raw" format. - """ - - def render_readonly(self, **kwargs): - value = self.raw_value - if not value: - return '' - return raw_datetime(self.request.rattail_config, value) - - -class DateTimePrettyFieldRenderer(fa.DateTimeFieldRenderer): - """ - Custom date/time field renderer, which displays a "pretty" value in - read-only mode, leveraging config to show the correct timezone. - """ - - def render_readonly(self, **kwargs): - value = self.raw_value - if not value: - return '' - return pretty_datetime(self.request.rattail_config, value) - - -class LocalDateTimeFieldRenderer(fa.DateTimeFieldRenderer): - """ - This renderer assumes the datetime field value is "naive" in local time zone. - """ - - def render_readonly(self, **kwargs): - value = self.raw_value - if not value: - return '' - value = localtime(self.request.rattail_config, value) - return raw_datetime(self.request.rattail_config, value) - - -class TimeFieldRenderer(fa.TimeFieldRenderer): - """ - Custom renderer for time fields. In edit mode, renders a simple text - input, which is expected to become a 'timepicker' widget in the UI. - However the particular magic required for that lives in 'tailbone.js'. - """ - format = '%I:%M %p' - - def render(self, **kwargs): - kwargs.setdefault('class_', 'timepicker') - return fa_helpers.text_field(self.name, value=self.value, **kwargs) - - def render_readonly(self, **kwargs): - return self.render_value(self.raw_value) - - def render_value(self, value): - value = self.convert_value(value) - if isinstance(value, datetime.time): - return value.strftime(self.format) - return '' - - def convert_value(self, value): - if isinstance(value, datetime.datetime): - if not value.tzinfo: - value = make_utc(value, tzinfo=True) - return localtime(self.request.rattail_config, value).time() - return value - - def stringify_value(self, value, as_html=False): - if not as_html: - return self.render_value(value) - return super(TimeFieldRenderer, self).stringify_value(value, as_html=as_html) - - def _serialized_value(self): - return self.params.getone(self.name) - - def deserialize(self): - value = self._serialized_value() - if value: - try: - return datetime.datetime.strptime(value, self.format).time() - except ValueError: - pass - - -class EmailFieldRenderer(fa.TextFieldRenderer): - """ - Renderer for email address fields - """ - - def render_readonly(self, **kwargs): - address = self.raw_value - if not address: - return '' - return tags.link_to(address, 'mailto:{}'.format(address)) - - -class EnumFieldRenderer(fa_fields.SelectFieldRenderer): - """ - Renderer for simple enumeration fields. - """ - enumeration = {} - render_key = False - - def __init__(self, arg, render_key=False): - if isinstance(arg, dict): - self.enumeration = arg - self.render_key = render_key - else: - self(arg) - - def __call__(self, field): - super(EnumFieldRenderer, self).__init__(field) - return self - - def render_readonly(self, **kwargs): - value = self.raw_value - if value is None: - return '' - rendered = self.enumeration.get(value, unicode(value)) - if self.render_key: - rendered = '{} - {}'.format(value, rendered) - return rendered - - def render(self, **kwargs): - opts = [(self.enumeration[x], x) for x in self.enumeration] - if not self.field.is_required(): - opts.insert(0, self.field._null_option) - return fa_fields.SelectFieldRenderer.render(self, opts, **kwargs) - - -class DecimalFieldRenderer(fa.FieldRenderer): - """ - Sort of generic field renderer for decimal values. You must provide the - number of places after the decimal (scale). Note that this in turn relies - on simple string formatting; the renderer does not attempt any mathematics - of its own. - """ - - def __init__(self, scale): - self.scale = scale - - def __call__(self, field): - super(DecimalFieldRenderer, self).__init__(field) - return self - - def render_readonly(self, **kwargs): - value = self.raw_value - if value is None: - return '' - fmt = '{{0:0.{0}f}}'.format(self.scale) - return fmt.format(value) - - -class CurrencyFieldRenderer(fa_fields.FloatFieldRenderer): - """ - Sort of generic field renderer for currency values. - """ - - def render_readonly(self, **kwargs): - value = self.raw_value - if value is None: - return '' - if value < 0: - return "(${:0,.2f})".format(0 - value) - return "${:0,.2f}".format(value) - - -class QuantityFieldRenderer(fa_fields.FloatFieldRenderer): - """ - Sort of generic field renderer for quantity values. - """ - - def render_readonly(self, **kwargs): - return pretty_quantity(self.raw_value) - - -class YesNoFieldRenderer(fa.CheckBoxFieldRenderer): - - def render_readonly(self, **kwargs): - value = self.raw_value - if value is None: - return u'' - return u'Yes' if value else u'No' diff --git a/tailbone/forms/renderers/core.py b/tailbone/forms/renderers/core.py deleted file mode 100644 index 37b2454d..00000000 --- a/tailbone/forms/renderers/core.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -Core Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -import datetime - -import formalchemy as fa -from formalchemy.fields import AbstractField -from pyramid.renderers import render - - -class CustomFieldRenderer(fa.FieldRenderer): - """ - Base class for renderers which accept customization args, and "fake out" - FormAlchemy by pretending to still be a renderer factory when in fact it's - already dealing with a renderer instance. - """ - - def __init__(self, *args, **kwargs): - if len(args) == 1 and isinstance(args[0], AbstractField): - super(CustomFieldRenderer, self).__init__(args[0]) - self.init(**kwargs) - else: - assert len(args) == 0 - self.init(**kwargs) - - def __call__(self, field): - super(CustomFieldRenderer, self).__init__(field) - return self - - def init(self, **kwargs): - pass - - @property - def rattail_config(self): - return self.request.rattail_config - - -class DateFieldRenderer(CustomFieldRenderer, fa.DateFieldRenderer): - """ - Date field renderer which uses jQuery UI datepicker widget when rendering - in edit mode. - """ - date_format = '%Y-%m-%d' - change_year = False - - def init(self, date_format=None, change_year=False): - if date_format: - self.date_format = date_format - self.change_year = change_year - - def render_readonly(self, **kwargs): - value = self.raw_value - if value is None: - return '' - return value.strftime(self.date_format) - - def render(self, **kwargs): - kwargs['name'] = self.name - kwargs['value'] = self.value - kwargs['change_year'] = self.change_year - return render('/forms/fields/date.mako', kwargs) - - def deserialize(self): - value = self._serialized_value() - if not value: - return None - try: - return datetime.datetime.strptime(value, '%Y-%m-%d') - except ValueError: - raise fa.ValidationError("Date value must be in YYYY-MM-DD format") - except Exception as error: - raise fa.ValidationError(unicode(error)) - - def _serialized_value(self): - return self.params.getone(self.name) diff --git a/tailbone/forms/renderers/files.py b/tailbone/forms/renderers/files.py deleted file mode 100644 index 08f12382..00000000 --- a/tailbone/forms/renderers/files.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -Batch Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -import os -import stat -import random - -from formalchemy.ext import fsblob -from formalchemy.fields import FileFieldRenderer as Base - - -class FileFieldRenderer(fsblob.FileFieldRenderer): - """ - Custom file field renderer. In readonly mode, shows a filename and its - size; in edit mode, supports a single file upload. - """ - - @classmethod - def new(cls, view, **kwargs): - name = b'Configured{}_{}'.format(cls.__name__, str(random.random())[2:]) - return type(name, (cls,), dict(view=view, **kwargs)) - - @property - def request(self): - return self.view.request - - @property - def storage_path(self): - return self.view.upload_dir - - def get_file_path(self): - """ - Returns the absolute path to the data file. - """ - if hasattr(self, 'file_path'): - return self.file_path - return self.field.value - - def get_size(self): - """ - Returns the size of the data file, in bytes. - """ - path = self.get_file_path() - if path and os.path.isfile(path): - return os.stat(path)[stat.ST_SIZE] - return 0 - - def get_url(self, filename): - """ - Must return a URL suitable for downloading the file - """ - url = self.get_download_url() - if url: - if callable(url): - return url(filename) - return url - return self.view.get_action_url('download', self.field.parent.model) - - def get_download_url(self): - if hasattr(self, 'download_url'): - return self.download_url - - def render(self, **kwargs): - return Base.render(self, **kwargs) - - def render_readonly(self, **kwargs): - """ - Render the filename and the binary size in a human readable with a link - to the file itself. - """ - value = self.get_file_path() - if value: - content = '{} ({})'.format(fsblob.normalized_basename(value), - self.readable_size()) - return fsblob.h.content_tag('a', content, - href=self.get_url(value), **kwargs) - return '' diff --git a/tailbone/forms/renderers/people.py b/tailbone/forms/renderers/people.py deleted file mode 100644 index 54b4aefa..00000000 --- a/tailbone/forms/renderers/people.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -People Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -import six -import formalchemy as fa -from webhelpers2.html import tags, HTML - -from tailbone.forms.renderers.common import AutocompleteFieldRenderer - - -class PersonFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Person` instance fields. - """ - service_route = 'people.autocomplete' - - def render_readonly(self, **kwargs): - person = self.raw_value - if not person: - return '' - return tags.link_to(person, self.request.route_url('people.view', uuid=person.uuid)) - - -class PeopleFieldRenderer(fa.FieldRenderer): - """ - Renderer for "people" list relationship - """ - - def render_readonly(self, **kwargs): - html = '' - people = self.raw_value - if not people: - return html - for person in people: - link = tags.link_to(person, self.request.route_url('people.view', uuid=person.uuid)) - html += HTML.tag('li', c=link) - html = HTML.tag('ul', c=html) - return html - - -class CustomerFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Customer` instance fields. - """ - - service_route = 'customers.autocomplete' - - def render_readonly(self, **kwargs): - customer = self.raw_value - if not customer: - return '' - text = self.render_value(customer) - return tags.link_to(text, self.request.route_url('customers.view', uuid=customer.uuid)) - - def render_value(self, customer): - return six.text_type(customer) diff --git a/tailbone/forms/renderers/products.py b/tailbone/forms/renderers/products.py deleted file mode 100644 index daca5dc5..00000000 --- a/tailbone/forms/renderers/products.py +++ /dev/null @@ -1,224 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -Product Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -import six - -from rattail.gpc import GPC -from rattail.db import model -from rattail.db.util import maxlen - -import formalchemy as fa -from formalchemy import TextFieldRenderer -from formalchemy.fields import SelectFieldRenderer -from webhelpers2.html import tags, literal - -from tailbone.forms.renderers.common import AutocompleteFieldRenderer -from tailbone.util import pretty_datetime - - -class ProductFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Product` instance fields. - """ - - service_route = 'products.autocomplete' - - @property - def field_display(self): - product = self.raw_value - if product: - return product.full_description - return '' - - def render_readonly(self, **kwargs): - product = self.raw_value - if not product: - return "" - render = kwargs.get('render_product', self.render_product) - text = render(product) - if kwargs.get('hyperlink', True): - return tags.link_to(text, self.request.route_url('products.view', uuid=product.uuid)) - return text - - def render_product(self, product): - return six.text_type(product) - - -class ProductKeyFieldRenderer(TextFieldRenderer): - """ - Base class for product key field renderers. - """ - - def render_readonly(self, **kwargs): - value = self.raw_value - if value is None: - return '' - value = self.render_value(value) - if kwargs.get('link'): - product = self.field.parent.model - value = tags.link_to(value, kwargs['link'](product)) - return value - - def render_value(self, value): - return unicode(value) - - -class GPCFieldRenderer(ProductKeyFieldRenderer): - """ - Renderer for :class:`rattail.gpc.GPC` fields. - """ - - @property - def length(self): - # Hm, should maybe consider hard-coding this...? - return len(unicode(GPC(0))) - - def render_value(self, gpc): - return gpc.pretty() - - -class ScancodeFieldRenderer(ProductKeyFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Product.scancode` field - """ - - @property - def length(self): - return maxlen(model.Product.scancode) - - -class DepartmentFieldRenderer(SelectFieldRenderer): - """ - Shows the department number as well as the name. - """ - - def render(self, **kwargs): - kwargs.setdefault('auto-enhance', 'true') - return super(DepartmentFieldRenderer, self).render(**kwargs) - - def render_readonly(self, **kwargs): - department = self.raw_value - if not department: - return '' - if department.number: - text = '({}) {}'.format(department.number, department.name) - else: - text = department.name - return tags.link_to(text, self.request.route_url('departments.view', uuid=department.uuid)) - - -class SubdepartmentFieldRenderer(SelectFieldRenderer): - """ - Shows a link to the subdepartment. - """ - - def render_readonly(self, **kwargs): - subdept = self.raw_value - if not subdept: - return "" - if subdept.number: - text = "({}) {}".format(subdept.number, subdept.name) - else: - text = subdept.name - return tags.link_to(text, self.request.route_url('subdepartments.view', uuid=subdept.uuid)) - - -class CategoryFieldRenderer(SelectFieldRenderer): - """ - Shows a link to the category. - """ - - def render_readonly(self, **kwargs): - category = self.raw_value - if not category: - return "" - if category.code: - text = "({}) {}".format(category.code, category.name) - else: - text = category.name - return tags.link_to(text, self.request.route_url('categories.view', uuid=category.uuid)) - - -class BrandFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Brand` instance fields. - """ - - service_route = 'brands.autocomplete' - - -class CostFieldRenderer(fa.FieldRenderer): - """ - Renders fields which reference a ProductCost object - """ - - def render_readonly(self, **kwargs): - cost = self.raw_value - if not cost: - return '' - return '${:0.2f}'.format(cost.unit_cost) - - -class PriceFieldRenderer(TextFieldRenderer): - """ - Renderer for fields which reference a :class:`ProductPrice` instance. - """ - - def render_readonly(self, **kwargs): - price = self.field.raw_value - if price: - if not price.product.not_for_sale: - if price.price is not None and price.pack_price is not None: - if price.multiple > 1: - return literal('$ %0.2f / %u ($ %0.2f / %u)' % ( - price.price, price.multiple, - price.pack_price, price.pack_multiple)) - return literal('$ %0.2f ($ %0.2f / %u)' % ( - price.price, price.pack_price, price.pack_multiple)) - if price.price is not None: - if price.multiple > 1: - return '$ %0.2f / %u' % (price.price, price.multiple) - return '$ %0.2f' % price.price - if price.pack_price is not None: - return '$ %0.2f / %u' % (price.pack_price, price.pack_multiple) - return '' - - -class PriceWithExpirationFieldRenderer(PriceFieldRenderer): - """ - Price field renderer which also displays the expiration date, if present. - """ - - def render_readonly(self, **kwargs): - result = super(PriceWithExpirationFieldRenderer, self).render_readonly(**kwargs) - if result: - price = self.field.raw_value - if price.ends: - result = '{0} ({1})'.format( - result, pretty_datetime(self.request.rattail_config, price.ends)) - return result diff --git a/tailbone/forms/renderers/users.py b/tailbone/forms/renderers/users.py deleted file mode 100644 index c6343492..00000000 --- a/tailbone/forms/renderers/users.py +++ /dev/null @@ -1,97 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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 Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -import six - -from rattail.db import model -from rattail.db.auth import has_permission, administrator_role - -import formalchemy -from webhelpers2.html import HTML, tags - -from tailbone.db import Session - - -class UserFieldRenderer(formalchemy.TextFieldRenderer): - """ - Renderer for :class:`rattail:rattail.db.model.User` instance fields. - """ - - def render_readonly(self, **kwargs): - user = self.raw_value - if not user: - return '' - title = six.text_type(user) - if kwargs.get('hyperlink') and self.request.has_perm('users.view'): - return tags.link_to(title, self.request.route_url('users.view', uuid=user.uuid)) - return title - - -def PermissionsFieldRenderer(permissions, include_guest=False, include_authenticated=False): - - class PermissionsFieldRenderer(formalchemy.FieldRenderer): - - def deserialize(self): - perms = [] - i = len(self.name) + 1 - for key in self.params: - if key.startswith(self.name): - perms.append(key[i:]) - return perms - - def _render(self, readonly=False, **kwargs): - principal = self.field.model - html = '' - for groupkey in sorted(permissions, key=lambda k: permissions[k]['label'].lower()): - inner = HTML.tag('p', c=permissions[groupkey]['label']) - perms = permissions[groupkey]['perms'] - rendered = False - for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): - checked = has_permission(Session(), principal, key, - include_guest=include_guest, - include_authenticated=include_authenticated) - if checked or not readonly: - label = perms[key]['label'] - if readonly: - span = HTML.tag('span', c="[X]" if checked else "[ ]") - inner += HTML.tag('p', class_='perm', c=span + ' ' + label) - else: - inner += tags.checkbox(self.name + '-' + key, - checked=checked, label=label) - rendered = True - if rendered: - html += HTML.tag('div', class_='group', c=inner) - return html or "(none granted)" - - def render(self, **kwargs): - return self._render(**kwargs) - - def render_readonly(self, **kwargs): - return self._render(readonly=True, **kwargs) - - return PermissionsFieldRenderer diff --git a/tailbone/forms/renderers/vendors.py b/tailbone/forms/renderers/vendors.py deleted file mode 100644 index 3b7cf28d..00000000 --- a/tailbone/forms/renderers/vendors.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -Vendor Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -from formalchemy.fields import SelectFieldRenderer -from webhelpers2.html import tags - -from tailbone.forms.renderers.common import AutocompleteFieldRenderer - - -class VendorFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Vendor` instance fields. - """ - service_route = 'vendors.autocomplete' - - def render_readonly(self, **kwargs): - vendor = self.raw_value - if not vendor: - return "" - text = "({}) {}".format(vendor.id, vendor.name) - if kwargs.get('hyperlink', True): - return tags.link_to(text, self.request.route_url('vendors.view', uuid=vendor.uuid)) - return text - - -class PurchaseFieldRenderer(SelectFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Purchase` relation fields. - """ - - def render_readonly(self, **kwargs): - purchase = self.raw_value - if not purchase: - return '' - return tags.link_to(purchase, self.request.route_url('purchases.view', uuid=purchase.uuid)) diff --git a/tailbone/forms/simpleform.py b/tailbone/forms/simpleform.py deleted file mode 100644 index c8e00653..00000000 --- a/tailbone/forms/simpleform.py +++ /dev/null @@ -1,82 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -Simple Forms -""" - -from __future__ import unicode_literals, absolute_import - -from rattail.util import prettify - -import pyramid_simpleform -from pyramid_simpleform import renderers -from webhelpers2.html import tags, HTML - -from tailbone.forms import Form - - -class SimpleForm(Form): - """ - Customized simple form. - """ - - def __init__(self, request, schema, obj=None, **kwargs): - super(SimpleForm, self).__init__(request, **kwargs) - self._form = pyramid_simpleform.Form(request, schema=schema, obj=obj) - - def __getattr__(self, attr): - return getattr(self._form, attr) - - def render(self, **kwargs): - kwargs['form'] = FormRenderer(self) - return super(SimpleForm, self).render(**kwargs) - - def validate(self): - return self._form.validate() - - -class FormRenderer(renderers.FormRenderer): - """ - Customized form renderer. Provides some extra methods for convenience. - """ - - def __getattr__(self, attr): - return getattr(self.form, attr) - - def field_div(self, name, field, label=None): - errors = self.errors_for(name) - if errors: - errors = [HTML.tag('div', class_='field-error', c=x) for x in errors] - errors = tags.literal('').join(errors) - - label = HTML.tag('label', for_=name, c=label or prettify(name)) - inner = HTML.tag('div', class_='field', c=field) - - outer_class = 'field-wrapper' - if errors: - outer_class += ' error' - outer = HTML.tag('div', class_=outer_class, c=(errors or '') + label + inner) - return outer - - def referrer_field(self): - return self.hidden('referrer', value=self.form.request.get_referrer()) diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py new file mode 100644 index 00000000..ac7f2d43 --- /dev/null +++ b/tailbone/forms/types.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Form Schema Types +""" + +import re +import datetime +import json + +from rattail.db import model +from rattail.gpc import GPC + +import colander + +from tailbone.db import Session +from tailbone.forms import widgets + + +class JQueryTime(colander.Time): + """ + Custom type for jQuery widget Time data. + """ + + def deserialize(self, node, cstruct): + if not cstruct: + return colander.null + + formats = [ + '%I:%M %p', + '%I:%M%p', + '%I %p', + '%I%p', + ] + for fmt in formats: + try: + return datetime.datetime.strptime(cstruct, fmt).time() + except ValueError: + pass + + # re-try first format, for "better" error message + return datetime.datetime.strptime(cstruct, formats[0]).time() + + +class DateTimeBoolean(colander.Boolean): + """ + Schema type which presents the user with a "boolean" whereas the underlying + node is really a datetime (assumed to be "naive" UTC, and allow nulls). + """ + + def deserialize(self, node, cstruct): + value = super(DateTimeBoolean, self).deserialize(node, cstruct) + if value: # else return None + return datetime.datetime.utcnow() + + +class FalafelDateTime(colander.DateTime): + """ + Custom schema node type for rattail UTC datetimes + """ + widget_maker = widgets.FalafelDateTimeWidget + + def __init__(self, *args, **kwargs): + request = kwargs.pop('request') + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, node, appstruct): + if not appstruct: + return {} + + # cant use isinstance; dt subs date + if type(appstruct) is datetime.date: + appstruct = datetime.datetime.combine(appstruct, datetime.time()) + + if not isinstance(appstruct, datetime.datetime): + raise colander.Invalid(node, f'"{appstruct}" is not a datetime object') + + if appstruct.tzinfo is None: + appstruct = appstruct.replace(tzinfo=self.default_tzinfo) + + app = self.request.rattail_config.get_app() + dt = app.localtime(appstruct, from_utc=True) + + return { + 'date': str(dt.date()), + 'time': str(dt.time()), + } + + def deserialize(self, node, cstruct): + if not cstruct: + return colander.null + + if not cstruct['date'] and not cstruct['time']: + return colander.null + + try: + date = datetime.datetime.strptime(cstruct['date'], '%Y-%m-%d').date() + except: + node.raise_invalid("Missing or invalid date") + + try: + time = datetime.datetime.strptime(cstruct['time'], '%H:%M:%S').time() + except: + node.raise_invalid("Missing or invalid time") + + result = datetime.datetime.combine(date, time) + + app = self.request.rattail_config.get_app() + result = app.localtime(result) + result = app.make_utc(result) + return result + + +class FalafelTime(colander.Time): + """ + Custom schema node type for simple time fields + """ + widget_maker = widgets.FalafelTimeWidget + + def __init__(self, *args, **kwargs): + request = kwargs.pop('request') + super().__init__(*args, **kwargs) + self.request = request + + +class GPCType(colander.SchemaType): + """ + Schema type for product GPC data. + """ + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + return str(appstruct) + + def deserialize(self, node, cstruct): + if not cstruct: + return None + digits = re.sub(r'\D', '', cstruct) + if not digits: + return None + try: + return GPC(digits) + except Exception as err: + raise colander.Invalid(node, str(err)) + + +class ProductQuantity(colander.MappingSchema): + """ + Combo schema type for product cases and units; useful for inventory, + ordering, receiving etc. Meant to be used with the ``CasesUnitsWidget``. + """ + cases = colander.SchemaNode(colander.Decimal(), missing=colander.null) + + units = colander.SchemaNode(colander.Decimal(), missing=colander.null) + + +class ModelType(colander.SchemaType): + """ + Custom schema type for scalar ORM relationship fields. + """ + model_class = None + session = None + + def __init__(self, model_class=None, session=None): + if model_class: + self.model_class = model_class + if session: + self.session = session + else: + self.session = self.make_session() + + def make_session(self): + return Session() + + @property + def model_title(self): + self.model_class.get_model_title() + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + return str(appstruct) + + def deserialize(self, node, cstruct): + if not cstruct: + return None + obj = self.session.get(self.model_class, cstruct) + if not obj: + raise colander.Invalid(node, "{} not found".format(self.model_title)) + return obj + + +# TODO: deprecate / remove this +ObjectType = ModelType + + +class StoreType(ModelType): + """ + Custom schema type for store field. + """ + model_class = model.Store + + +class CustomerType(ModelType): + """ + Custom schema type for customer field. + """ + model_class = model.Customer + + +class DepartmentType(ModelType): + """ + Custom schema type for department field. + """ + model_class = model.Department + + +class EmployeeType(ModelType): + """ + Custom schema type for employee field. + """ + model_class = model.Employee + + +class VendorType(ModelType): + """ + Custom schema type for vendor relationship field. + """ + model_class = model.Vendor + + +class ProductType(ModelType): + """ + Custom schema type for product relationship field. + """ + model_class = model.Product + + +class UserType(ModelType): + """ + Custom schema type for user field. + """ + model_class = model.User diff --git a/tailbone/forms/validators.py b/tailbone/forms/validators.py deleted file mode 100644 index fa5ab4ea..00000000 --- a/tailbone/forms/validators.py +++ /dev/null @@ -1,153 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -Custom Form Validators -""" - -from __future__ import unicode_literals, absolute_import - -import re - -from rattail.db import model -from rattail.db.util import validate_email_address, validate_phone_number -from rattail.gpc import GPC - -import formencode as fe -import formalchemy as fa - -from tailbone.db import Session - - -class ValidGPC(fe.validators.FancyValidator): - """ - Validator for fields which should contain GPC value. - """ - - def _to_python(self, value, state): - if value is not None: - digits = re.sub(r'\D', '', value) - if digits: - try: - return GPC(digits) - except ValueError as error: - raise fe.Invalid("Invalid UPC: {}".format(error), value, state) - - def _from_python(self, upc, state): - if upc is None: - return '' - return upc.pretty() - - -class ModelValidator(fe.validators.FancyValidator): - """ - Generic validator for data model reference fields. - """ - model_class = None - - @property - def model_name(self): - self.model_class.__name__ - - def _to_python(self, value, state): - if value: - obj = Session.query(self.model_class).get(value) - if obj: - return obj - raise fe.Invalid("{} not found".format(self.model_name), value, state) - - def _from_python(self, value, state): - obj = value - if not obj: - return '' - return obj.uuid - - def validate_python(self, value, state): - obj = value - if obj is not None and not isinstance(obj, self.model_class): - raise fe.Invalid("Value must be a valid {} object".format(self.model_name), value, state) - - -class ValidStore(ModelValidator): - """ - Validator for store field. - """ - model_class = model.Store - - -class ValidCustomer(ModelValidator): - """ - Validator for customer field. - """ - model_class = model.Customer - - -class ValidDepartment(ModelValidator): - """ - Validator for department field. - """ - model_class = model.Department - - -class ValidEmployee(ModelValidator): - """ - Validator for employee field. - """ - model_class = model.Employee - - -class ValidProduct(ModelValidator): - """ - Validator for product field. - """ - model_class = model.Product - - -class ValidUser(ModelValidator): - """ - Validator for user field. - """ - model_class = model.User - - -def valid_email_address(value, field=None): - """ - FormAlchemy-compatible validation function, which leverages FormEncode - under the hood. - """ - if value: - try: - return validate_email_address(value, error=True) - except Exception as error: - raise fa.ValidationError(unicode(error)) - - -def valid_phone_number(value, field=None): - """ - FormAlchemy-compatible validation function, which leverages FormEncode - under the hood. - """ - if value: - try: - return validate_phone_number(value, error=True) - except Exception as error: - raise fa.ValidationError(unicode(error)) diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py new file mode 100644 index 00000000..8c16726d --- /dev/null +++ b/tailbone/forms/widgets.py @@ -0,0 +1,659 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Form Widgets +""" + +import json +import datetime +import decimal +import re + +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags, HTML + +from tailbone.db import Session + + +class ReadonlyWidget(dfwidget.HiddenWidget): + + readonly = True + + def serialize(self, field, cstruct, **kw): + """ """ + if cstruct in (colander.null, None): + cstruct = '' + # TODO: is this hacky? + text = kw.get('text') + if not text: + text = field.parent.tailbone_form.render_field_value(field.name) + return HTML.tag('span', text) + tags.hidden(field.name, value=cstruct, id=field.oid) + + +class NumberInputWidget(dfwidget.TextInputWidget): + template = 'numberinput' + autocomplete = 'off' + + +class NumericInputWidget(NumberInputWidget): + """ + This widget uses a ``<numeric-input>`` component, which will + leverage the ``numeric.js`` functions to ensure user doesn't enter + any non-numeric values. Note that this still uses a normal "text" + input on the HTML side, as opposed to a "number" input, since the + latter is a bit ugly IMHO. + """ + template = 'numericinput' + allow_enter = True + + +class PercentInputWidget(dfwidget.TextInputWidget): + """ + Custom text input widget, used for "percent" type fields. This widget + assumes that the underlying storage for the value is a "traditional" + percent value, e.g. ``0.36135`` - but the UI should represent this as a + "human-friendly" value, e.g. ``36.135 %``. + """ + template = 'percentinput' + autocomplete = 'off' + + def serialize(self, field, cstruct, **kw): + """ """ + if cstruct not in (colander.null, None): + # convert "traditional" value to "human-friendly" + value = decimal.Decimal(cstruct) * 100 + value = value.quantize(decimal.Decimal('0.001')) + cstruct = str(value) + return super().serialize(field, cstruct, **kw) + + def deserialize(self, field, pstruct): + """ """ + pstruct = super().deserialize(field, pstruct) + if pstruct is colander.null: + return colander.null + # convert "human-friendly" value to "traditional" + try: + value = decimal.Decimal(pstruct) + except decimal.InvalidOperation: + raise colander.Invalid(field.schema, "Invalid decimal string: {}".format(pstruct)) + value = value.quantize(decimal.Decimal('0.00001')) + value /= 100 + return str(value) + + +class CasesUnitsWidget(dfwidget.Widget): + """ + Widget for collecting case and/or unit quantities. Most useful when you + need to ensure user provides cases *or* units but not both. + """ + template = 'cases_units' + amount_required = False + one_amount_only = False + + def serialize(self, field, cstruct, **kw): + """ """ + if cstruct in (colander.null, None): + cstruct = '' + readonly = kw.get('readonly', self.readonly) + kw['cases'] = cstruct['cases'] or '' + kw['units'] = cstruct['units'] or '' + template = readonly and self.readonly_template or self.template + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + + def deserialize(self, field, pstruct): + """ """ + from tailbone.forms.types import ProductQuantity + + if pstruct is colander.null: + return colander.null + + schema = ProductQuantity() + try: + validated = schema.deserialize(pstruct) + except colander.Invalid as exc: + raise colander.Invalid(field.schema, "Invalid pstruct: %s" % exc) + + if self.amount_required and not (validated['cases'] or validated['units']): + raise colander.Invalid(field.schema, "Must provide case or unit amount", + value=validated) + + if self.amount_required and self.one_amount_only and validated['cases'] and validated['units']: + raise colander.Invalid(field.schema, "Must provide case *or* unit amount, " + "but *not* both", value=validated) + + return validated + + +class DynamicCheckboxWidget(dfwidget.CheckboxWidget): + """ + This checkbox widget can be "dynamic" in the sense that form logic can + control its value and state. + """ + template = 'checkbox_dynamic' + + +# TODO: deprecate / remove this +class PlainSelectWidget(dfwidget.SelectWidget): + template = 'select_plain' + + +class CustomSelectWidget(dfwidget.SelectWidget): + """ + This widget is mostly for convenience. You can set extra kwargs for the + :meth:`serialize()` method, e.g.:: + + widget.set_template_values(foo='bar') + """ + + def set_template_values(self, **kw): + if not hasattr(self, 'extra_template_values'): + self.extra_template_values = {} + self.extra_template_values.update(kw) + + def get_template_values(self, field, cstruct, kw): + values = super().get_template_values(field, cstruct, kw) + if hasattr(self, 'extra_template_values'): + values.update(self.extra_template_values) + return values + + +class DynamicSelectWidget(CustomSelectWidget): + """ + This is a "normal" select widget, but instead of (or in addition to) its + values being set when constructed, they must be assigned dynamically in + real-time, e.g. based on other user selections. + + Really all this widget "does" is render some Vue.js-compatible HTML, but + the page which contains the widget is ultimately responsible for wiring up + the logic for things to work right. + """ + template = 'select_dynamic' + + +class JQuerySelectWidget(dfwidget.SelectWidget): + template = 'select_jquery' + + +class PlainDateWidget(dfwidget.DateInputWidget): + template = 'date_plain' + + +class JQueryDateWidget(dfwidget.DateInputWidget): + """ + Uses the jQuery datepicker UI widget, instead of whatever it is deform uses + by default. + """ + template = 'date_jquery' + type_name = 'text' + requirements = None + + default_options = ( + ('changeMonth', True), + ('changeYear', True), + ('dateFormat', 'yy-mm-dd'), + ) + + def serialize(self, field, cstruct, **kw): + """ """ + if cstruct in (colander.null, None): + cstruct = '' + readonly = kw.get('readonly', self.readonly) + template = readonly and self.readonly_template or self.template + options = dict( + kw.get('options') or self.options or self.default_options + ) + options.update(kw.get('extra_options', {})) + kw.setdefault('options_json', json.dumps(options)) + kw.setdefault('selected_callback', None) + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + + +class JQueryTimeWidget(dfwidget.TimeInputWidget): + """ + Uses the jQuery datepicker UI widget, instead of whatever it is deform uses + by default. + """ + template = 'time_jquery' + type_name = 'text' + requirements = None + default_options = ( + ('showPeriod', True), + ) + + +class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget): + """ + Custom widget for rattail UTC datetimes + """ + template = 'datetime_falafel' + + new_pattern = re.compile(r'^\d\d?:\d\d:\d\d [AP]M$') + + def serialize(self, field, cstruct, **kw): + """ """ + readonly = kw.get('readonly', self.readonly) + values = self.get_template_values(field, cstruct, kw) + template = self.readonly_template if readonly else self.template + return field.renderer(template, **values) + + def deserialize(self, field, pstruct): + """ """ + if pstruct == '': + return colander.null + + # nb. we now allow '4:20:00 PM' on the widget side, but the + # true node needs it to be '16:20:00' instead + if self.new_pattern.match(pstruct['time']): + time = datetime.datetime.strptime(pstruct['time'], '%I:%M:%S %p') + pstruct['time'] = time.strftime('%H:%M:%S') + + return pstruct + + +class FalafelTimeWidget(dfwidget.TimeInputWidget): + """ + Custom widget for simple time fields + """ + template = 'time_falafel' + + def deserialize(self, field, pstruct): + """ """ + if pstruct == '': + return colander.null + return pstruct + + +class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): + """ + Uses the jQuery autocomplete plugin, instead of whatever it is deform uses + by default. + """ + template = 'autocomplete_jquery' + requirements = None + field_display = "" + assigned_label = None + service_url = None + cleared_callback = None + selected_callback = None + input_callback = None + new_label_callback = None + ref = None + + default_options = ( + ('autoFocus', True), + ) + options = None + + def serialize(self, field, cstruct, **kw): + """ """ + if 'delay' in kw or getattr(self, 'delay', None): + raise ValueError( + 'AutocompleteWidget does not support *delay* parameter ' + 'any longer.' + ) + if cstruct in (colander.null, None): + cstruct = '' + self.values = self.values or [] + readonly = kw.get('readonly', self.readonly) + + options = dict( + kw.get('options') or self.options or self.default_options + ) + options['source'] = self.service_url + + kw['options'] = json.dumps(options) + kw['field_display'] = self.field_display + kw['cleared_callback'] = self.cleared_callback + kw['assigned_label'] = self.assigned_label + kw['input_callback'] = self.input_callback + kw['new_label_callback'] = self.new_label_callback + kw['ref'] = self.ref + kw.setdefault('selected_callback', self.selected_callback) + tmpl_values = self.get_template_values(field, cstruct, kw) + template = readonly and self.readonly_template or self.template + return field.renderer(template, **tmpl_values) + + +class FileUploadWidget(dfwidget.FileUploadWidget): + """ + Widget to handle file upload. Must override to add ``use_oruga`` + to field template context. + """ + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop('request') + super().__init__(*args, **kwargs) + + def get_template_values(self, field, cstruct, kw): + values = super().get_template_values(field, cstruct, kw) + if self.request: + values['use_oruga'] = self.request.use_oruga + return values + + +class MultiFileUploadWidget(dfwidget.FileUploadWidget): + """ + Widget to handle multiple (arbitrary number) of file uploads. + """ + template = 'multi_file_upload' + requirements = () + + def serialize(self, field, cstruct, **kw): + """ """ + if cstruct in (colander.null, None): + cstruct = [] + + if cstruct: + for fileinfo in cstruct: + uid = fileinfo['uid'] + if uid not in self.tmpstore: + self.tmpstore[uid] = fileinfo + + readonly = kw.get("readonly", self.readonly) + template = readonly and self.readonly_template or self.template + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + + def deserialize(self, field, pstruct): + """ """ + if pstruct is colander.null: + return colander.null + + # TODO: why is this a thing? pstruct == [b''] + if len(pstruct) == 1 and pstruct[0] == b'': + return colander.null + + files_data = [] + for upload in pstruct: + + data = self.deserialize_upload(upload) + if data: + files_data.append(data) + + if not files_data: + return colander.null + + return files_data + + def deserialize_upload(self, upload): + """ """ + # nb. this logic was copied from parent class and adapted + # to allow for multiple files. needs some more love. + + uid = None # TODO? + + if hasattr(upload, "file"): + # the upload control had a file selected + data = dfwidget.filedict() + data["fp"] = upload.file + filename = upload.filename + # sanitize IE whole-path filenames + filename = filename[filename.rfind("\\") + 1 :].strip() + data["filename"] = filename + data["mimetype"] = upload.type + data["size"] = upload.length + if uid is None: + # no previous file exists + while 1: + uid = self.random_id() + if self.tmpstore.get(uid) is None: + data["uid"] = uid + self.tmpstore[uid] = data + preview_url = self.tmpstore.preview_url(uid) + self.tmpstore[uid]["preview_url"] = preview_url + break + else: + # a previous file exists + data["uid"] = uid + self.tmpstore[uid] = data + preview_url = self.tmpstore.preview_url(uid) + self.tmpstore[uid]["preview_url"] = preview_url + else: + # the upload control had no file selected + if uid is None: + # no previous file exists + return colander.null + else: + # a previous file should exist + data = self.tmpstore.get(uid) + # but if it doesn't, don't blow up + if data is None: + return colander.null + return data + + +def make_customer_widget(request, **kwargs): + """ + Make a customer widget; will be either autocomplete or dropdown + depending on config. + """ + # use autocomplete widget by default + factory = CustomerAutocompleteWidget + + # caller may request dropdown widget + if kwargs.pop('dropdown', False): + factory = CustomerDropdownWidget + + else: # or, config may say to use dropdown + if request.rattail_config.getbool( + 'rattail', 'customers.choice_uses_dropdown', + default=False): + factory = CustomerDropdownWidget + + # instantiate whichever + return factory(request, **kwargs) + + +class CustomerAutocompleteWidget(JQueryAutocompleteWidget): + """ + Autocomplete widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + app = self.request.rattail_config.get_app() + model = app.model + + # must figure out URL providing autocomplete service + if 'service_url' not in kwargs: + + # caller can just pass 'url' instead of 'service_url' + if 'url' in kwargs: + self.service_url = kwargs['url'] + + else: # use default url + self.service_url = self.request.route_url('customers.autocomplete') + + # TODO + if 'input_callback' not in kwargs: + if 'input_handler' in kwargs: + self.input_callback = input_handler + + def serialize(self, field, cstruct, **kw): + """ """ + # fetch customer to provide button label, if we have a value + if cstruct: + app = self.request.rattail_config.get_app() + model = app.model + customer = Session.get(model.Customer, cstruct) + if customer: + self.field_display = str(customer) + + return super().serialize( + field, cstruct, **kw) + + +class CustomerDropdownWidget(dfwidget.SelectWidget): + """ + Dropdown widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + app = self.request.rattail_config.get_app() + + # must figure out dropdown values, if they weren't given + if 'values' not in kwargs: + + # use what caller gave us, if they did + if 'customers' in kwargs: + customers = kwargs['customers'] + if callable(customers): + customers = customers() + + else: # default customer list + customers = app.get_clientele_handler()\ + .get_all_customers(Session()) + + # convert customer list to option values + self.values = [(c.uuid, c.name) + for c in customers] + + +class DepartmentWidget(dfwidget.SelectWidget): + """ + Custom select widget for a Department reference field. + + Constructor accepts the normal ``values`` kwarg but if not + provided then the widget will fetch department list from Rattail + DB. + + Constructor also accepts ``required`` kwarg, which defaults to + true unless specified. + """ + + def __init__(self, request, **kwargs): + + if 'values' not in kwargs: + app = request.rattail_config.get_app() + model = app.model + departments = Session.query(model.Department)\ + .order_by(model.Department.number) + values = [(dept.uuid, str(dept)) + for dept in departments] + if not kwargs.pop('required', True): + values.insert(0, ('', "(none)")) + kwargs['values'] = values + + super().__init__(**kwargs) + + +def make_vendor_widget(request, **kwargs): + """ + Make a vendor widget; will be either autocomplete or dropdown + depending on config. + """ + # use autocomplete widget by default + factory = VendorAutocompleteWidget + + # caller may request dropdown widget + if kwargs.pop('dropdown', False): + factory = VendorDropdownWidget + + else: # or, config may say to use dropdown + app = request.rattail_config.get_app() + vendor_handler = app.get_vendor_handler() + if vendor_handler.choice_uses_dropdown(): + factory = VendorDropdownWidget + + # instantiate whichever + return factory(request, **kwargs) + + +class VendorAutocompleteWidget(JQueryAutocompleteWidget): + """ + Autocomplete widget for a Vendor reference field. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + app = self.request.rattail_config.get_app() + model = app.model + + # must figure out URL providing autocomplete service + if 'service_url' not in kwargs: + + # caller can just pass 'url' instead of 'service_url' + if 'url' in kwargs: + self.service_url = kwargs['url'] + + else: # use default url + self.service_url = self.request.route_url('vendors.autocomplete') + + # # TODO + # if 'input_callback' not in kwargs: + # if 'input_handler' in kwargs: + # self.input_callback = input_handler + + def serialize(self, field, cstruct, **kw): + """ """ + # fetch vendor to provide button label, if we have a value + if cstruct: + app = self.request.rattail_config.get_app() + model = app.model + vendor = Session.get(model.Vendor, cstruct) + if vendor: + self.field_display = str(vendor) + + return super().serialize( + field, cstruct, **kw) + + +class VendorDropdownWidget(dfwidget.SelectWidget): + """ + Dropdown widget for a Vendor reference field. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + # must figure out dropdown values, if they weren't given + if 'values' not in kwargs: + + # use what caller gave us, if they did + if 'vendors' in kwargs: + vendors = kwargs['vendors'] + if callable(vendors): + vendors = vendors() + + else: # default vendor list + app = self.request.rattail_config.get_app() + model = app.model + vendors = Session.query(model.Vendor)\ + .order_by(model.Vendor.name)\ + .all() + + # convert vendor list to option values + self.values = [(c.uuid, c.name) + for c in vendors] diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py deleted file mode 100644 index 06183d4c..00000000 --- a/tailbone/forms2/core.py +++ /dev/null @@ -1,489 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Forms Core -""" - -from __future__ import unicode_literals, absolute_import - -import datetime -import logging - -import six -import sqlalchemy as sa -from sqlalchemy import orm -from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY - -from rattail.time import localtime -from rattail.util import prettify, pretty_boolean, pretty_hours - -import colander -from colanderalchemy import SQLAlchemySchemaNode -import deform -from deform import widget as dfwidget -from pyramid.renderers import render -from webhelpers2.html import tags, HTML - -from tailbone.util import raw_datetime - - -log = logging.getLogger(__name__) - - -class CustomSchemaNode(SQLAlchemySchemaNode): - - def get_schema_from_relationship(self, prop, overrides): - """ Build and return a :class:`colander.SchemaNode` for a relationship. - """ - - # for some reason ColanderAlchemy wants to crawl our entire ORM by - # default, by way of relationships. this 'excludes' hack is used to - # prevent that, by forcing skip of 2nd-level relationships - - excludes = [] - if isinstance(prop, orm.RelationshipProperty): - for next_prop in prop.mapper.iterate_properties: - if isinstance(next_prop, orm.RelationshipProperty): - excludes.append(next_prop.key) - - if excludes: - overrides['excludes'] = excludes - - return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides) - - def dictify(self, obj): - """ Return a dictified version of `obj` using schema information. - - .. note:: - This method was copied from upstream and modified to add automatic - handling of "association proxy" fields. - """ - dict_ = super(CustomSchemaNode, self).dictify(obj) - for node in self: - - name = node.name - if name not in dict_: - - try: - desc = getattr(self.inspector.all_orm_descriptors, name) - if desc.extension_type != ASSOCIATION_PROXY: - continue - value = getattr(obj, name) - except AttributeError: - continue - - if value is None: - if isinstance(node.typ, colander.String): - # colander has an issue with `None` on a String type - # where it translates it into "None". Let's check - # for that specific case and turn it into a - # `colander.null`. - dict_[name] = colander.null - else: - # A specific case this helps is with Integer where - # `None` is an invalid value. We call serialize() - # to test if we have a value that will work later - # for serialization and then allow it if it doesn't - # raise an exception. Hopefully this also catches - # issues with user defined types and future issues. - try: - node.serialize(value) - except: - dict_[name] = colander.null - else: - dict_[name] = value - else: - dict_[name] = value - - return dict_ - - def objectify(self, dict_, context=None): - """ Return an object representing ``dict_`` using schema information. - - .. note:: - This method was copied from upstream and modified to add automatic - handling of "association proxy" fields. - """ - mapper = self.inspector - context = mapper.class_() if context is None else context - for attr in dict_: - if mapper.has_property(attr): - prop = mapper.get_property(attr) - if hasattr(prop, 'mapper'): - cls = prop.mapper.class_ - if prop.uselist: - # Sequence of objects - value = [self[attr].children[0].objectify(obj) - for obj in dict_[attr]] - else: - # Single object - value = self[attr].objectify(dict_[attr]) - else: - value = dict_[attr] - if value is colander.null: - # `colander.null` is never an appropriate - # value to be placed on an SQLAlchemy object - # so we translate it into `None`. - value = None - setattr(context, attr, value) - - else: - # try to process association proxy field - desc = mapper.all_orm_descriptors.get(attr) - if desc and desc.extension_type == ASSOCIATION_PROXY: - value = dict_[attr] - if value is colander.null: - # `colander.null` is never an appropriate - # value to be placed on an SQLAlchemy object - # so we translate it into `None`. - value = None - setattr(context, attr, value) - - else: - # Ignore attributes if they are not mapped - log.debug( - 'SQLAlchemySchemaNode.objectify: %s not found on ' - '%s. This property has been ignored.', - attr, self - ) - continue - - return context - - -class Form(object): - """ - Base class for all forms. - """ - - def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], - model_instance=None, model_class=None, nodes={}, enums={}, labels={}, renderers={}, - widgets={}, defaults={}, validators={}, required={}, action_url=None, cancel_url=None): - - self.fields = list(fields) if fields is not None else None - self.schema = schema - self.request = request - self.readonly = readonly - self.readonly_fields = set(readonly_fields or []) - self.model_instance = model_instance - self.model_class = model_class - if self.model_instance and not self.model_class: - self.model_class = type(self.model_instance) - if self.model_class and self.fields is None: - self.fields = self.make_fields() - self.nodes = nodes or {} - self.enums = enums or {} - self.labels = labels or {} - self.renderers = renderers or {} - self.widgets = widgets or {} - self.defaults = defaults or {} - self.validators = validators or {} - self.required = required or {} - self.action_url = action_url - self.cancel_url = cancel_url - - def make_fields(self): - """ - Return a default list of fields, based on :attr:`model_class`. - """ - if not self.model_class: - raise ValueError("Must define model_class to use make_fields()") - - mapper = orm.class_mapper(self.model_class) - - # first add primary column fields - fields = [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 remove_field(self, key): - if key in self.fields: - self.fields.remove(key) - - def make_schema(self): - if not self.model_class: - # TODO - raise NotImplementedError - - if not self.schema: - - mapper = orm.class_mapper(self.model_class) - - # first filter our "full" field list so we ignore certain ones. in - # particular we don't want readonly fields in the schema, or any - # which appear to be "private" - includes = [f for f in self.fields - if f not in self.readonly_fields - and not f.startswith('_') - and f != 'versions'] - - # make schema - only include *property* fields at this point - schema = CustomSchemaNode(self.model_class, - includes=[p.key for p in mapper.iterate_properties - if p.key in includes]) - - # for now, must manually add any "extra" fields? this includes all - # association proxy fields, not sure how other fields will behave - for field in includes: - if field not in schema: - node = self.nodes.get(field) - if not node: - node = colander.SchemaNode(colander.String(), name=field, missing='') - schema.add(node) - - # apply any label overrides - for key, label in self.labels.items(): - if key in schema: - schema[key].title = label - - # apply any widget overrides - for key, widget in self.widgets.items(): - if key in schema: - schema[key].widget = widget - - # apply any default values - for key, default in self.defaults.items(): - if key in schema: - schema[key].default = default - - # apply any validators - for key, validator in self.validators.items(): - if key in schema: - schema[key].validator = validator - - # apply required flags - for key, required in self.required.items(): - if key in schema: - if required: - schema[key].missing = colander.required - else: - schema[key].missing = None # TODO? - - self.schema = schema - - return self.schema - - def set_label(self, key, label): - self.labels[key] = label - - def get_label(self, key): - return self.labels.get(key, prettify(key)) - - def set_readonly(self, key, readonly=True): - if readonly: - self.readonly_fields.add(key) - else: - if key in self.readonly_fields: - self.readonly_fields.remove(key) - - def set_node(self, key, node): - self.nodes[key] = node - - def set_type(self, key, type_): - if type_ == 'datetime': - self.set_renderer(key, self.render_datetime) - elif type_ == 'datetime_local': - self.set_renderer(key, self.render_datetime_local) - elif type_ == 'duration': - self.set_renderer(key, self.render_duration) - elif type_ == 'boolean': - self.set_renderer(key, self.render_boolean) - self.set_widget(key, dfwidget.CheckboxWidget(true_val='True', false_val='False')) - elif type_ == 'currency': - self.set_renderer(key, self.render_currency) - elif type_ == 'enum': - self.set_renderer(key, self.render_enum) - elif type_ == 'codeblock': - self.set_renderer(key, self.render_codeblock) - self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) - elif type_ == 'text': - self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) - else: - raise ValueError("unknown type for '{}' field: {}".format(key, type_)) - - def set_enum(self, key, enum): - if enum: - self.enums[key] = enum - self.set_type(key, 'enum') - else: - self.enums.pop(key, None) - - def set_renderer(self, key, renderer): - self.renderers[key] = renderer - - def set_widget(self, key, widget): - self.widgets[key] = widget - - def set_validator(self, key, validator): - self.validators[key] = validator - - def set_required(self, key, required=True): - """ - Set whether or not value is required for a given field. - """ - self.required[key] = required - - def set_default(self, key, value): - """ - Set the default value for a given field. - """ - self.defaults[key] = value - - def render(self, template=None, **kwargs): - if not template: - if self.readonly: - template = '/forms2/form_readonly.mako' - else: - template = '/forms2/form.mako' - context = kwargs - context['form'] = self - return render(template, context) - - def make_deform_form(self): - if not hasattr(self, 'deform_form'): - - schema = self.make_schema() - - # get initial form values from model instance - kwargs = {} - if self.model_instance: - kwargs['appstruct'] = schema.dictify(self.model_instance) - - # create form - form = deform.Form(schema, **kwargs) - - # set readonly widget where applicable - for field in self.readonly_fields: - if field in form: - form[field].widget = ReadonlyWidget() - - self.deform_form = form - - return self.deform_form - - def render_deform(self, dform=None, template='/forms2/deform.mako', **kwargs): - 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() - - context = kwargs - context['form'] = self - context['dform'] = dform - context['request'] = self.request - context['readonly_fields'] = self.readonly_fields - context['render_field_readonly'] = self.render_field_readonly - return render('/forms2/deform.mako', context) - - def render_field_readonly(self, field_name, **kwargs): - 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) - return HTML.tag('div', class_='field-wrapper {}'.format(field), c=label + field_div) - - def render_field_value(self, field_name): - record = self.model_instance - if self.renderers and field_name in self.renderers: - return self.renderers[field_name](record, field_name) - return self.render_generic(record, field_name) - - def render_generic(self, record, field_name): - value = self.obtain_value(record, field_name) - if value is None: - return "" - return six.text_type(value) - - def render_datetime(self, record, field_name): - value = self.obtain_value(record, field_name) - if value is None: - return "" - return raw_datetime(self.request.rattail_config, value) - - def render_datetime_local(self, record, field_name): - value = self.obtain_value(record, field_name) - if value is None: - return "" - value = localtime(self.request.rattail_config, 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: - return "" - return pretty_hours(datetime.timedelta(seconds=value)) - - def render_boolean(self, record, field_name): - value = self.obtain_value(record, field_name) - return pretty_boolean(value) - - def render_currency(self, record, field_name): - value = self.obtain_value(record, field_name) - if value is None: - return "" - if value < 0: - return "(${:0,.2f})".format(0 - value) - return "${:0,.2f}".format(value) - - def render_enum(self, record, field_name): - value = self.obtain_value(record, field_name) - if value is None: - return "" - enum = self.enums.get(field_name) - if enum and value in enum: - return six.text_type(enum[value]) - return six.text_type(value) - - def render_codeblock(self, record, field_name): - value = self.obtain_value(record, field_name) - if value is None: - return "" - return HTML.tag('pre', value) - - def obtain_value(self, record, field_name): - try: - return record[field_name] - except TypeError: - return getattr(record, field_name) - - def validate(self, *args, **kwargs): - form = self.make_deform_form() - return form.validate(*args, **kwargs) - - -class ReadonlyWidget(dfwidget.HiddenWidget): - - readonly = True - - def serialize(self, field, cstruct, **kw): - if cstruct in (colander.null, None): - cstruct = '' - return HTML.tag('span', cstruct) + tags.hidden(field.name, value=cstruct, id=field.oid) diff --git a/tailbone/grids/__init__.py b/tailbone/grids/__init__.py index 0d4970c8..7db22b26 100644 --- a/tailbone/grids/__init__.py +++ b/tailbone/grids/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -28,4 +28,3 @@ from __future__ import unicode_literals, absolute_import from . import filters from .core import Grid, GridAction -from .mobile import MobileGrid diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 7729a536..56b97b86 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.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,123 +24,497 @@ Core Grid Classes """ -from __future__ import unicode_literals, absolute_import +import inspect +import logging +import warnings +from urllib.parse import urlencode -import datetime -import urllib - -import six 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 -class Grid(object): +log = logging.getLogger(__name__) + + +class Grid(WuttaGrid): """ - Core grid class. In sore need of documentation. + 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 __init__(self, key, data, columns=None, width='auto', request=None, mobile=False, model_class=None, - enums={}, labels={}, renderers={}, extra_row_class=None, linked_columns=[], url='#', - joiners={}, filterable=False, filters={}, - sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', - pageable=False, default_pagesize=20, default_page=1, - checkboxes=False, checked=None, main_actions=[], more_actions=[], - **kwargs): + def __init__( + self, + request, + key=None, + data=None, + width='auto', + model_title=None, + model_title_plural=None, + enums={}, + assume_local_times=False, + invisible=[], + raw_renderers={}, + extra_row_class=None, + url='#', + use_byte_string_filters=False, + checkboxes=False, + checked=None, + check_handler=None, + check_all_handler=None, + checkable=None, + row_uuid_getter=None, + clicking_row_checks_box=False, + click_handlers=None, + main_actions=[], + more_actions=[], + delete_speedbump=False, + ajax_data_url=None, + expose_direct_link=False, + **kwargs, + ): + if 'component' in kwargs: + warnings.warn("component param is deprecated for Grid(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('vue_tagname', kwargs.pop('component')) + + if 'default_sortkey' in kwargs: + warnings.warn("default_sortkey param is deprecated for Grid(); " + "please use sort_defaults param instead", + DeprecationWarning, stacklevel=2) + if 'default_sortdir' in kwargs: + warnings.warn("default_sortdir param is deprecated for Grid(); " + "please use sort_defaults param instead", + DeprecationWarning, stacklevel=2) + if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs: + sortkey = kwargs.pop('default_sortkey', None) + sortdir = kwargs.pop('default_sortdir', 'asc') + if sortkey: + kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) + + if 'pageable' in kwargs: + warnings.warn("pageable param is deprecated for Grid(); " + "please use paginated param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('paginated', kwargs.pop('pageable')) + + if 'default_pagesize' in kwargs: + warnings.warn("default_pagesize param is deprecated for Grid(); " + "please use pagesize param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('pagesize', kwargs.pop('default_pagesize')) + + if 'default_page' in kwargs: + warnings.warn("default_page param is deprecated for Grid(); " + "please use page param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('page', kwargs.pop('default_page')) + + if 'searchable' in kwargs: + warnings.warn("searchable param is deprecated for Grid(); " + "please use searchable_columns param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('searchable_columns', kwargs.pop('searchable')) + + # TODO: this should not be needed once all templates correctly + # reference grid.vue_component etc. + kwargs.setdefault('vue_tagname', 'tailbone-grid') + + # nb. these must be set before super init, as they are + # referenced when constructing filters + self.assume_local_times = assume_local_times + self.use_byte_string_filters = use_byte_string_filters + + kwargs['key'] = key + kwargs['data'] = data + super().__init__(request, **kwargs) + + self.model_title = model_title + if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'): + self.model_title = self.model_class.get_model_title() + + self.model_title_plural = model_title_plural + if not self.model_title_plural: + if self.model_class and hasattr(self.model_class, 'get_model_title_plural'): + self.model_title_plural = self.model_class.get_model_title_plural() + if not self.model_title_plural: + self.model_title_plural = '{}s'.format(self.model_title) - self.key = key - self.data = data - self.columns = columns self.width = width - self.request = request - self.mobile = mobile - self.model_class = model_class - if self.model_class and self.columns is None: - self.columns = self.make_columns() self.enums = enums or {} - - self.labels = labels or {} - self.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.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 if self.checked is None: self.checked = lambda item: False - self.main_actions = main_actions - self.more_actions = more_actions + self.check_handler = check_handler + self.check_all_handler = check_all_handler + self.checkable = checkable + self.row_uuid_getter = row_uuid_getter + self.clicking_row_checks_box = clicking_row_checks_box + self.click_handlers = click_handlers or {} + + self.delete_speedbump = delete_speedbump + + if ajax_data_url: + self.ajax_data_url = ajax_data_url + elif self.request: + self.ajax_data_url = self.request.path_url + else: + self.ajax_data_url = '' + + 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 - 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()") + @property + def component(self): + """ """ + warnings.warn("Grid.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname - mapper = orm.class_mapper(self.model_class) - return [prop.key for prop in mapper.iterate_properties] + @property + def component_studly(self): + """ """ + warnings.warn("Grid.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component + + 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 + + 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')] + + 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): - if key in self.columns: - self.columns.remove(key) + """ + This *removes* a column from the grid, altogether. + + This method is deprecated; use :meth:`remove()` instead. + """ + warnings.warn("Grid.hide_column() is deprecated; please use " + "Grid.remove() instead.", + DeprecationWarning, stacklevel=2) + self.remove(key) + + def hide_columns(self, *keys): + """ + This *removes* columns from the grid, altogether. + + This method is deprecated; use :meth:`remove()` instead. + """ + self.remove(*keys) + + def set_invisible(self, key, invisible=True): + """ + Mark the given column as "invisible" (but do not remove it). + + Use :meth:`remove()` if you actually want to remove it. + """ + if invisible: + if key not in self.invisible: + self.invisible.append(key) + else: + if key in self.invisible: + self.invisible.remove(key) + + def insert_before(self, field, newfield): + self.columns.insert_before(field, newfield) + + def insert_after(self, field, newfield): + self.columns.insert_after(field, newfield) + + def replace(self, oldfield, newfield): + self.insert_after(oldfield, newfield) + 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) + """ """ + + 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: + 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_label(self, key, label): - self.labels[key] = label - if key in self.filters: - self.filters[key].label = label + def set_click_handler(self, key, handler): + if handler: + self.click_handlers[key] = handler + else: + self.click_handlers.pop(key, None) - 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 has_click_handler(self, key): + return key in self.click_handlers - def set_renderer(self, key, renderer): - self.renderers[key] = renderer + def set_raw_renderer(self, key, renderer): + """ + Set or remove the "raw" renderer for the given field. + + See :attr:`raw_renderers` for more about these. + + :param key: Field name. + + :param renderer: Either a renderer callable, or ``None``. + """ + if renderer: + self.raw_renderers[key] = renderer + else: + self.raw_renderers.pop(key, None) def set_type(self, key, type_): if type_ == 'boolean': @@ -161,6 +535,8 @@ class Grid(object): self.set_renderer(key, self.render_quantity) elif type_ == 'duration': self.set_renderer(key, self.render_duration) + elif type_ == 'duration_hours': + self.set_renderer(key, self.render_duration_hours) else: raise ValueError("Unsupported type for column '{}': {}".format(key, type_)) @@ -168,6 +544,8 @@ class Grid(object): if enum: self.enums[key] = enum self.set_type(key, 'enum') + if key in self.filters: + self.filters[key].set_value_renderer(gridfilters.EnumValueRenderer(enum)) else: self.enums.pop(key, None) @@ -179,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) @@ -202,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): @@ -211,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) @@ -221,20 +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.2f}".format(value) + 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) + seconds = self.obtain_value(obj, column_name) + if seconds is None: + return "" + 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(datetime.timedelta(seconds=value)) + app = self.request.rattail_config.get_app() + return app.render_duration(hours=value) def set_url(self, url): self.url = url @@ -244,59 +650,15 @@ 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['mobile'] = self.mobile - 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.make_webhelpers_grid_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_webhelpers_grid_renderers(self): + def make_default_renderers(self, renderers): """ - Return a dict of renderers for the webhelpers grid. We honor any - existing renderers which have already been set, but then we also try to - supplement that by auto-assigning renderers based on underlying column - type. Note that this special logic only applies to grids with a valid - :attr:`model_class`. - """ - renderers = dict(self.renderers) + Make the default set of column renderers for the grid. + We honor any existing renderers which have already been set, but then + we also try to supplement that by auto-assigning renderers based on + underlying column type. Note that this special logic only applies to + grids with a valid :attr:`model_class`. + """ if self.model_class: mapper = orm.class_mapper(self.model_class) for prop in mapper.iterate_properties: @@ -316,7 +678,10 @@ class Grid(object): return self.render_boolean if isinstance(coltype, sa.DateTime): - return self.render_datetime + if self.assume_local_times: + return self.render_datetime_local + else: + return self.render_datetime if isinstance(coltype, GPCType): return self.render_gpc @@ -329,16 +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 - 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', '')) - 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): """ @@ -352,20 +714,18 @@ class Grid(object): 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'): - filters[prop.key] = self.make_filter(prop.key, prop.columns[0]) + if not isinstance(prop, orm.ColumnProperty): + continue + if prop.key.endswith('uuid'): + continue + if len(prop.columns) != 1: + continue + column = prop.columns[0] + if isinstance(column.type, sa.LargeBinary): + continue + 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. @@ -377,24 +737,32 @@ 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.AlchemyNumericFilter + factory = gridfilters.AlchemyIntegerFilter elif isinstance(column.type, sa.Boolean): # TODO: check column for nullable here? factory = gridfilters.AlchemyNullableBooleanFilter 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 - return factory(key, column=column, config=self.request.rattail_config, **kwargs) + kwargs['column'] = column + kwargs.setdefault('config', self.request.rattail_config) + kwargs.setdefault('encode_values', self.use_byte_string_filters) + 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): """ @@ -405,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(): @@ -472,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 @@ -500,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: @@ -529,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'] @@ -549,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: @@ -571,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): @@ -608,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') @@ -778,114 +1164,183 @@ 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. - """ - if not self.model_class: - return data - - # 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. + 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_vue_columns() + + if 'grid_data' not in kwargs: + kwargs['grid_data'] = self.get_table_data() + + if 'static_data' not in kwargs: + kwargs['static_data'] = self.has_static_data() + + if self.filterable and 'filters_data' not in kwargs: + kwargs['filters_data'] = self.get_filters_data() + + if self.filterable and 'filters_sequence' not in kwargs: + kwargs['filters_sequence'] = self.get_filters_sequence() + context = kwargs context['grid'] = self + context['request'] = self.request context.setdefault('allow_save_defaults', True) - return render(template, context) + context.setdefault('view_click_handler', self.get_view_click_handler()) + html = render(template, context) + return HTML.literal(html) - def render_filters(self, template='/grids/filters.mako', **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): """ - Render the filters to a Unicode string, using the specified template. - Additional kwargs are passed along as context to the template. + 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_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.actions: + if action.key == 'view': + return getattr(action, 'click_handler', None) + + def set_filters_sequence(self, filters, only=False): + """ + Explicitly set the sequence for grid filters, using the sequence + provided. If the grid currently has more filters than are mentioned in + the given sequence, the sequence will come first and all others will be + tacked on at the end. + + :param filters: Sequence of filter keys, i.e. field names. + + :param only: If true, then *only* those filters specified will + be kept, and all others discarded. If false then any + filters not specified will still be tacked onto the end, in + alphabetical order. + """ + new_filters = gridfilters.GridFilterSet() + for field in filters: + if field in self.filters: + new_filters[field] = self.filters.pop(field) + else: + log.warning("field '%s' is not in current filter set", field) + if not only: + for field in sorted(self.filters): + new_filters[field] = self.filters[field] + self.filters = new_filters + + def get_filters_sequence(self): + """ + Returns a list of filter keys (strings) in the sequence with which they + should be displayed in the UI. + """ + return list(self.filters) + + def get_filters_data(self): + """ + Returns a dict of current filters data, for use with index view. """ - # 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 + for filtr in self.filters.values(): - form = gridfilters.GridFiltersForm(self.request, self.filters, defaults=data) + valueless = [v for v in filtr.valueless_verbs + if v in filtr.verbs] - kwargs['request'] = self.request - kwargs['grid'] = self - kwargs['form'] = gridfilters.GridFiltersFormRenderer(form) - return render(template, kwargs) + multiple_values = [v for v in filtr.multiple_value_verbs + if v in filtr.verbs] - def render_actions(self, row, i): - """ - Returns the rendered contents of the 'actions' column for a given row. - """ - main_actions = filter(None, [self.render_action(a, row, i) for a in self.main_actions]) - more_actions = filter(None, [self.render_action(a, row, i) for a in self.more_actions]) - 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) + choices = [] + choice_labels = {} + if filtr.choices: + choices = list(filtr.choices) + choice_labels = dict(filtr.choices) + elif self.enums and filtr.key in self.enums: + choices = list(self.enums[filtr.key]) + choice_labels = self.enums[filtr.key] + + data[filtr.key] = { + 'key': filtr.key, + 'label': filtr.label, + 'active': filtr.active, + 'visible': filtr.active, + 'verbs': filtr.verbs, + 'valueless_verbs': valueless, + 'multiple_value_verbs': multiple_values, + 'verb_labels': filtr.verb_labels, + 'verb': filtr.verb or filtr.default_verb or filtr.verbs[0], + 'value': str(filtr.value) if filtr.value is not None else "", + 'data_type': filtr.data_type, + 'choices': choices, + 'choice_labels': choice_labels, + } + + return data + + def render_actions(self, row, i): # pragma: no cover + """ """ + warnings.warn("grid.render_actions() is deprecated!", + DeprecationWarning, stacklevel=2) + + 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) + + def render_action(self, action, row, i): # pragma: no cover + """ """ + warnings.warn("grid.render_action() is deprecated!", + DeprecationWarning, stacklevel=2) - 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} @@ -919,107 +1374,236 @@ class Grid(object): return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)), checked=self.checked(item)) - def get_pagesize_options(self): - # TODO: Make configurable or something... - return [5, 10, 20, 50, 100] + def has_static_data(self): + """ + Should return ``True`` if the grid data can be considered "static" + (i.e. a list of values). Will return ``False`` otherwise, e.g. if the + data is represented as a SQLAlchemy query. + """ + # TODO: should make this smarter? + if isinstance(self.data, list): + return True + return False + def get_vue_columns(self): + """ """ + columns = super().get_vue_columns() -class CustomWebhelpersGrid(webhelpers2_grid.Grid): - """ - Implement column sorting links etc. for webhelpers2_grid - """ + for column in columns: + column['visible'] = column['field'] not in self.invisible - def __init__(self, itemlist, columns, **kwargs): - self.mobile = kwargs.pop('mobile', False) - 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) + return columns - def default_header_record_format(self, headers): - if self.mobile: - return HTML('') - return super(CustomWebhelpersGrid, self).default_header_record_format(headers) + 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 generate_header_link(self, column_number, column, label_text): + def get_uuid_for_row(self, rowobj): - # 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) + # use custom getter if set + if self.row_uuid_getter: + return self.row_uuid_getter(rowobj) - # 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) + # 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 + client-side JS table. + """ + if hasattr(self, '_table_data'): + return self._table_data + + # filter / sort / paginate to get "visible" data + raw_data = self.get_visible_data() + data = [] + status_map = {} + checked = [] + + # we check for 'all' method and if so, assume we have a Query; + # otherwise we assume it's something we can use len() with, which could + # be a list or a Paginator + if hasattr(raw_data, 'all'): + count = raw_data.count() else: - return self.default_header_column_format(column_number, column, - label_text) + count = len(raw_data) - def default_record_format(self, i, record, columns): - if self.mobile: - return columns - kwargs = { - 'class_': self.get_record_class(i, record, columns), + # iterate over data rows + checkable = self.checkboxes and self.checkable and callable(self.checkable) + for i in range(count): + rowobj = raw_data[i] + + # 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 + # the grid. this can be used for front-end editing of row data for + # instance, when the "display" version is different than raw data. + # here is the hack we use for that. + columns = list(self.columns) + if hasattr(self, '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: + 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 = "" + + # 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: + 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) + + # set extra row class if applicable + if self.extra_row_class: + status = self.extra_row_class(rowobj, i) + if status: + status_map[i] = status + + # set checked flag if applicable + if self.checkboxes: + if self.checked(rowobj): + checked.append(i) + + data.append(row) + + results = { + 'data': data, + 'row_classes': status_map, + # TODO: deprecate / remove this + 'row_status_map': status_map, } - 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) + if self.checkboxes: + 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.vue_component) + results['checked_rows_code'] = '[{}]'.format( + ', '.join(['{}[{}]'.format(var, i) for i in checked])) + + 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 + results['pages'] = self.pager.page_count + results['first_item'] = self.pager.first_item + results['last_item'] = self.pager.last_item 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 + results['total_items'] = count - 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) + self._table_data = results + return self._table_data - def default_column_format(self, column_number, i, record, column_name): - value = self.get_column_value(column_number, i, record, column_name) - if self.mobile: - url = self.url_generator(record, i) - attrs = {} - if hasattr(record, 'uuid'): - attrs['data_uuid'] = record.uuid - return HTML.tag('li', tags.link_to(value, url), **attrs) - if self.linked_columns and column_name in self.linked_columns 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) + # 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 client-side table, since we can't generate URLs from JS. + """ + for action in self.actions: + url = action.get_url(rowobj, i) + row['_action_url_{}'.format(action.key)] = url -class GridAction(object): +class GridAction(WuttaGridAction): """ - Represents an action available to a grid. This is used to construct the - 'actions' column when rendering the 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, key, label=None, url='#', icon=None, target=None): - self.key = key - self.label = label or prettify(key) - self.icon = icon - self.url = url + 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', '#') + + super().__init__(request, key, **kwargs) + self.target = target - - 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 + self.click_handler = click_handler class URLMaker(object): @@ -1035,5 +1619,5 @@ class URLMaker(object): params = self.request.GET.copy() params["page"] = page params["partial"] = "1" - qs = urllib.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 87523d32..7e52bb8d 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.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,24 +24,24 @@ 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 -from pyramid_simpleform import Form -from pyramid_simpleform.renderers import FormRenderer +import colander from webhelpers2.html import HTML, tags +from tailbone import forms + log = logging.getLogger(__name__) @@ -50,6 +50,7 @@ class FilterValueRenderer(object): """ Base class for all filter renderers. """ + data_type = 'string' @property def name(self): @@ -73,6 +74,7 @@ class NumericValueRenderer(FilterValueRenderer): """ Input renderer for numeric values. """ + data_type = 'number' def render(self, value=None, **kwargs): kwargs.setdefault('step', '0.001') @@ -83,15 +85,18 @@ class DateValueRenderer(FilterValueRenderer): """ Input renderer for date values. """ + data_type = 'date' def render(self, value=None, **kwargs): - return tags.text(self.name, value=value, type='date', **kwargs) + kwargs['data-datepicker'] = 'true' + return tags.text(self.name, value=value, **kwargs) class ChoiceValueRenderer(FilterValueRenderer): """ Renders value input as a dropdown/selectmenu of available choices. """ + data_type = 'choice' def __init__(self, options): self.options = options @@ -106,8 +111,11 @@ class EnumValueRenderer(ChoiceValueRenderer): """ def __init__(self, enum): - 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] + if isinstance(enum, OrderedDict): + sorted_keys = list(enum.keys()) + else: + sorted_keys = sorted(enum, key=lambda k: enum[k].lower()) + self.options = [tags.Option(enum[k], str(k)) for k in sorted_keys] class GridFilter(object): @@ -119,35 +127,77 @@ class GridFilter(object): 'is_any': "is any", 'equal': "equal to", 'not_equal': "not equal to", + 'equal_any_of': "equal to any of", 'greater_than': "greater than", 'greater_equal': "greater than or equal to", 'less_than': "less than", '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", 'is_false': "is false", + 'is_false_null': "is false or null", + 'is_empty_or_null': "is either empty or null", 'contains': "contains", 'does_not_contain': "does not contain", + 'contains_any_of': "contains any of", 'is_me': "is me", 'is_not_me': "is not me", } - valueless_verbs = ['is_any', 'is_null', 'is_not_null', 'is_true', 'is_false', - 'is_me', 'is_not_me'] + valueless_verbs = [ + 'is_any', + 'is_empty', + 'is_not_empty', + 'is_null', + 'is_not_null', + 'is_true', + 'is_false', + 'is_false_null', + 'is_empty_or_null', + 'is_me', + 'is_not_me', + ] + + multiple_value_verbs = [ + 'equal_any_of', + 'contains_any_of', + ] value_renderer_factory = DefaultValueRenderer + data_type = 'string' # default, but will be set from value renderer + choices = {} - def __init__(self, key, label=None, verbs=None, value_renderer=None, - default_active=False, default_verb=None, default_value=None, **kwargs): + 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) + + 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.set_value_renderer(value_renderer or self.value_renderer_factory) + self.default_active = default_active self.default_verb = default_verb self.default_value = default_value - for key, value in kwargs.iteritems(): + + self.encode_values = encode_values + self.value_encoding = value_encoding + + for key, value in kwargs.items(): setattr(self, key, value) def __repr__(self): @@ -165,6 +215,48 @@ class GridFilter(object): return verbs return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'] + def normalize_choices(self, choices): + """ + Normalize a set of "choices" to a format suitable for use with the + filter. + + :param choices: A collection of "choices" in one of the following + formats: + + * simple list, each value of which should be a string, which is + assumed to be able to serve as both key and value (ordering of + choices will be preserved) + * simple dict, keys and values of which will define the choices + (note that the final choices will be sorted by key!) + * OrderedDict, keys and values of which will define the choices + (ordering of choices will be preserved) + """ + if isinstance(choices, OrderedDict): + normalized = choices + + elif isinstance(choices, dict): + normalized = OrderedDict([ + (key, choices[key]) + for key in sorted(choices)]) + + elif isinstance(choices, list): + normalized = OrderedDict([ + (key, key) + for key in choices]) + + return normalized + + def set_choices(self, choices): + """ + Set the value choices for the filter. Note that this also will set the + value renderer to one which supports choices. + + :param choices: A collection of "choices" which will be normalized by + way of :meth:`normalize_choices()`. + """ + self.choices = self.normalize_choices(choices) + self.set_value_renderer(ChoiceValueRenderer(self.choices)) + def set_value_renderer(self, renderer): """ Set the value renderer for the filter, post-construction. @@ -173,6 +265,7 @@ class GridFilter(object): renderer = renderer() renderer.filter = self self.value_renderer = renderer + self.data_type = renderer.data_type def filter(self, data, verb=None, value=UNSPECIFIED): """ @@ -184,12 +277,18 @@ 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, str): + return value.encode('utf-8') + return value + def filter_is_any(self, data, value): """ Special no-op filter which does no actual filtering. Useful in some @@ -208,18 +307,6 @@ class GridFilter(object): return self.value_renderer.render(value=value, **kwargs) -class MobileFilter(GridFilter): - """ - Base class for mobile grid filters. - """ - default_verbs = ['equal'] - - def __init__(self, key, **kwargs): - kwargs.setdefault('default_active', True) - kwargs.setdefault('default_verb', 'equal') - super(MobileFilter, self).__init__(key, **kwargs) - - class AlchemyGridFilter(GridFilter): """ Base class for SQLAlchemy grid filters. @@ -227,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): """ @@ -235,7 +322,7 @@ class AlchemyGridFilter(GridFilter): """ if value is None or value == '': return query - return query.filter(self.column == value) + return query.filter(self.column == self.encode_value(value)) def filter_not_equal(self, query, value): """ @@ -248,9 +335,41 @@ class AlchemyGridFilter(GridFilter): # include things which are nothing at all, in our result set. return query.filter(sa.or_( self.column == None, - self.column != value, + self.column != self.encode_value(value), )) + def filter_equal_any_of(self, query, value): + """ + This filter expects "multiple values" separated by newline + character, and will add an "OR" condition with each value + being checked separately. For instance if the user submits a + "value" like this: + + .. code-block:: none + + foo bar + baz + + This will result in SQL condition like this: + + .. code-block:: sql + + name = 'foo bar' OR name = 'baz' + """ + if not value: + return query + + values = value.split('\n') + values = [value for value in values if value] + if not values: + return query + + conditions = [] + for value in values: + conditions.append(self.column == self.encode_value(value)) + + return query.filter(sa.or_(*conditions)) + def filter_is_null(self, query, value): """ Filter data with an 'IS NULL' query. Note that this filter does not @@ -271,7 +390,7 @@ class AlchemyGridFilter(GridFilter): """ if value is None or value == '': return query - return query.filter(self.column > value) + return query.filter(self.column > self.encode_value(value)) def filter_greater_equal(self, query, value): """ @@ -279,7 +398,7 @@ class AlchemyGridFilter(GridFilter): """ if value is None or value == '': return query - return query.filter(self.column >= value) + return query.filter(self.column >= self.encode_value(value)) def filter_less_than(self, query, value): """ @@ -287,7 +406,7 @@ class AlchemyGridFilter(GridFilter): """ if value is None or value == '': return query - return query.filter(self.column < value) + return query.filter(self.column < self.encode_value(value)) def filter_less_equal(self, query, value): """ @@ -295,7 +414,48 @@ class AlchemyGridFilter(GridFilter): """ if value is None or value == '': return query - return query.filter(self.column <= value) + 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): @@ -307,8 +467,17 @@ 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', - 'equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'] + 'contains_any_of', + 'equal', 'not_equal', 'equal_any_of', + 'is_empty', 'is_not_empty', + 'is_null', 'is_not_null', + 'is_empty_or_null', + 'is_any'] def filter_contains(self, query, value): """ @@ -316,8 +485,13 @@ class AlchemyStringFilter(AlchemyGridFilter): """ if value is None or value == '': return query - return query.filter(sa.and_( - *[self.column.ilike('%{}%'.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): """ @@ -326,13 +500,65 @@ 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('%{0}%'.format(v)) for v in value.split()]), - )) + sa.and_(*criteria))) + + def filter_contains_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 via + "ILIKE". 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 ILIKE '%foo%' AND name ILIKE '%bar%') OR name ILIKE '%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: + 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.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')) + + def filter_is_not_empty(self, query, value): + return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')) + + def filter_is_empty_or_null(self, query, value): + return query.filter( + sa.or_( + sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''), + self.column == None)) class AlchemyEmptyStringFilter(AlchemyStringFilter): @@ -344,13 +570,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter): return query.filter( sa.or_( self.column == None, - sa.func.trim(self.column) == '')) + 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) != '')) + sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))) class AlchemyByteStringFilter(AlchemyStringFilter): @@ -362,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, unicode): + value = super().get_value(value) + if isinstance(value, str): value = value.encode(self.value_encoding) return value @@ -373,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): """ @@ -383,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): @@ -398,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 @@ -409,37 +645,94 @@ class AlchemyNumericFilter(AlchemyGridFilter): # term for integer field... def value_invalid(self, value): - return bool(value and len(unicode(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: + if isinstance(value, int): + return True + if not value.isdigit(): + return True + # normal Integer columns have a max value, beyond which PG + # will throw an error if we try to query for larger values + # TODO: this seems hacky, how to better handle it? + if not self.bigint and int(value) > 2147483647: + return True + return False + + def encode_value(self, value): + # ensure we pass integer value to sqlalchemy, so it does not try to + # encode it as a string etc. + return int(value) + + +class AlchemyBigIntegerFilter(AlchemyIntegerFilter): + """ + BigInteger filter for SQLAlchemy. + """ + bigint = True class AlchemyBooleanFilter(AlchemyGridFilter): @@ -467,7 +760,16 @@ class AlchemyNullableBooleanFilter(AlchemyBooleanFilter): """ Boolean filter for SQLAlchemy which is NULL-aware. """ - default_verbs = ['is_true', 'is_false', 'is_null', 'is_not_null', 'is_any'] + default_verbs = ['is_true', 'is_false', 'is_false_null', + 'is_null', 'is_not_null', 'is_any'] + + def filter_is_false_null(self, query, value): + """ + Filter data with an "is false or null" query. Note that this filter + does not use the value for anything. + """ + return query.filter(sa.or_(self.column == False, + self.column == None)) class AlchemyDateFilter(AlchemyGridFilter): @@ -483,6 +785,7 @@ class AlchemyDateFilter(AlchemyGridFilter): 'greater_equal': "on or after", 'less_than': "before", 'less_equal': "on or before", + 'between': "between", 'is_null': "is null", 'is_not_null': "is not null", 'is_any': "is any", @@ -492,14 +795,27 @@ class AlchemyDateFilter(AlchemyGridFilter): """ Expose greater-than / less-than verbs in addition to core. """ - return ['equal', 'not_equal', 'greater_than', 'greater_equal', - 'less_than', 'less_equal', 'is_null', 'is_not_null', 'is_any'] + return [ + 'equal', + 'not_equal', + 'greater_than', + 'greater_equal', + 'less_than', + 'less_equal', + 'between', + 'is_null', + 'is_not_null', + 'is_any', + ] def make_date(self, value): """ 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: @@ -507,6 +823,87 @@ 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 "<=" + (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_date, end_date = values + if start_date: + start_date = self.make_date(start_date) + if end_date: + end_date = self.make_date(end_date) + + # we'll only filter if we have start and/or end date + if not start_date and not end_date: + return query + + 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 + the given date range. Subclasses may override this logic. + """ + if start_date: + query = query.filter(self.column >= start_date) + if end_date: + query = query.filter(self.column <= end_date) + return query + class AlchemyDateTimeFilter(AlchemyDateFilter): """ @@ -596,6 +993,17 @@ class AlchemyDateTimeFilter(AlchemyDateFilter): time = make_utc(localtime(self.config, time)) return query.filter(self.column < time) + def filter_date_range(self, query, start_date, end_date): + if start_date: + start_time = datetime.datetime.combine(start_date, datetime.time(0)) + start_time = localtime(self.config, start_time) + query = query.filter(self.column >= make_utc(start_time)) + if end_date: + end_time = datetime.datetime.combine(end_date + datetime.timedelta(days=1), datetime.time(0)) + end_time = localtime(self.config, end_time) + query = query.filter(self.column <= make_utc(end_time)) + return query + class AlchemyLocalDateTimeFilter(AlchemyDateTimeFilter): """ @@ -686,19 +1094,36 @@ class AlchemyLocalDateTimeFilter(AlchemyDateTimeFilter): time = localtime(self.config, time, tzinfo=False) return query.filter(self.column < time) + def filter_date_range(self, query, start_date, end_date): + if start_date: + start_time = datetime.datetime.combine(start_date, datetime.time(0)) + start_time = localtime(self.config, start_time, tzinfo=False) + query = query.filter(self.column >= start_time) + if end_date: + end_time = datetime.datetime.combine(end_date + datetime.timedelta(days=1), datetime.time(0)) + end_time = localtime(self.config, end_time, tzinfo=False) + query = query.filter(self.column <= end_time) + return query + class AlchemyGPCFilter(AlchemyGridFilter): """ GPC filter for SQLAlchemy. """ - default_verbs = ['equal', 'not_equal'] + default_verbs = ['equal', 'not_equal', 'equal_any_of', + 'is_null', 'is_not_null'] def filter_equal(self, query, value): """ Filter data with an equal ('=') query. """ - if value is None or value == '': + if value is None: return query + + value = value.strip() + if not value: + return query + try: return query.filter(self.column.in_(( GPC(value), @@ -708,9 +1133,13 @@ class AlchemyGPCFilter(AlchemyGridFilter): def filter_not_equal(self, query, value): """ - Filter data with a not eqaul ('!=') query. + Filter data with a not equal ('!=') query. """ - if value is None or value == '': + if value is None: + return query + + value = value.strip() + if not value: return query # When saying something is 'not equal' to something else, we must also @@ -724,65 +1153,142 @@ class AlchemyGPCFilter(AlchemyGridFilter): except ValueError: return query + 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 via + "ILIKE". For instance if the user submits a "value" like this: + + .. code-block:: none + + 07430500132 + 07430500116 + + This will result in SQL condition like this: + + .. code-block:: sql + + (upc IN (7430500132, 74305001321)) OR (upc IN (7430500116, 74305001161)) + """ + 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: + try: + clause = self.column.in_(( + GPC(value), + GPC(value, calc_check_digit='upc'))) + except ValueError: + pass + else: + conditions.append(clause) + + if not conditions: + return query + + return query.filter(sa.or_(*conditions)) + + +class AlchemyPhoneNumberFilter(AlchemyStringFilter): + """ + Special string filter, with logic to deal with phone numbers. + """ + + def parse_value(self, value): + newvalue = None + + # first we try to split according to typical 7- or 10-digit number + digits = re.sub(r'\D', '', value or '') + if len(digits) == 7: + newvalue = "{} {}".format(digits[:3], digits[3:]) + elif len(digits) == 10: + newvalue = "{} {} {}".format(digits[:3], digits[3:6], digits[6:]) + + # if that didn't work, we can also try to split by grouped digits + if not newvalue and value: + parts = re.split(r'\D+', value) + newvalue = ' '.join(parts) + + return newvalue or value + + def filter_contains(self, query, value): + """ + Try to parse the value into "parts" of a phone number, then do a normal + 'ILIKE' query with those parts. + """ + value = self.parse_value(value) + return super().filter_contains(query, value) + + def filter_does_not_contain(self, query, value): + """ + Try to parse the value into "parts" of a phone number, then do a normal + 'NOT ILIKE' query with those parts. + """ + value = self.parse_value(value) + return super().filter_does_not_contain(query, value) + class GridFilterSet(OrderedDict): """ Collection class for :class:`GridFilter` instances. """ + def move_before(self, key, refkey): + """ + Rearrange underlying key sorting, such that the given ``key`` comes + just *before* the given ``refkey``. + """ + # first must work out the new order for all keys + newkeys = [] + for k in self.keys(): + if k == key: + continue + if k == refkey: + newkeys.append(key) + newkeys.append(refkey) + else: + newkeys.append(k) -class GridFiltersForm(Form): + # then effectively replace dict contents, using new order + items = dict(self) + self.clear() + for k in newkeys: + self[k] = items[k] + + +class GridFiltersForm(forms.Form): """ Form for grid filters. """ - def __init__(self, request, filters, *args, **kwargs): - super(GridFiltersForm, self).__init__(request, *args, **kwargs) + def __init__(self, filters, **kwargs): self.filters = filters + if 'schema' not in kwargs: + schema = colander.Schema() + for key, filtr in self.filters.items(): + node = colander.SchemaNode(colander.String(), name=key) + schema.add(node) + kwargs['schema'] = schema + super().__init__(**kwargs) def iter_filters(self): - return self.filters.itervalues() - - -class GridFiltersFormRenderer(FormRenderer): - """ - Renderer for :class:`GridFiltersForm` instances. - """ - - @property - def filters(self): - return self.form.filters - - def iter_filters(self): - return self.form.iter_filters() - - def tag(self, *args, **kwargs): - """ - Convenience method which passes all args to the - :meth:`webhelpers2:webhelpers2.html.builder.HTMLBuilder.tag()` method. - """ - return HTML.tag(*args, **kwargs) - - # TODO: This seems hacky..? - def checkbox(self, name, checked=None, **kwargs): - """ - Custom checkbox implementation. - """ - if name.endswith('-active'): - return tags.checkbox(name, checked=checked, **kwargs) - if checked is None: - checked = False - return super(GridFiltersFormRenderer, self).checkbox(name, checked=checked, **kwargs) + return self.filters.values() def filter_verb(self, filtr): """ Render the verb selection dropdown for the given filter. """ - options = [(v, filtr.verb_labels.get(v, "unknown verb '{0}'".format(v))) + options = [tags.Option(filtr.verb_labels.get(v, "unknown verb '{}'".format(v)), v) for v in filtr.verbs] hide_values = [v for v in filtr.valueless_verbs if v in filtr.verbs] - return self.select('{0}.verb'.format(filtr.key), options, **{ + return tags.select('{}.verb'.format(filtr.key), filtr.verb, options, **{ 'class_': 'verb', 'data-hide-value-for': ' '.join(hide_values)}) @@ -792,4 +1298,4 @@ class GridFiltersFormRenderer(FormRenderer): """ style = 'display: none;' if filtr.verb in filtr.valueless_verbs else None return HTML.tag('div', class_='value', style=style, - c=filtr.render_value(**kwargs)) + c=[filtr.render_value(**kwargs)]) 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 83c79518..50b38c30 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.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,18 +24,21 @@ 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 +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, pretty_datetime +from tailbone.util import (pretty_datetime, raw_datetime, + render_markdown, + route_exists) def pretty_date(date): diff --git a/tailbone/menus.py b/tailbone/menus.py new file mode 100644 index 00000000..09d6f3f0 --- /dev/null +++ b/tailbone/menus.py @@ -0,0 +1,772 @@ +# -*- 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/>. +# +################################################################################ +""" +App Menus +""" + +import logging +import warnings + +from rattail.util import prettify, simple_error + +from webhelpers2.html import tags, HTML + +from wuttaweb.menus import MenuHandler as WuttaMenuHandler + +from tailbone.db import Session + + +log = logging.getLogger(__name__) + + +class TailboneMenuHandler(WuttaMenuHandler): + """ + Base class and default implementation for menu handler. + """ + + ############################## + # internal methods + ############################## + + def _is_allowed(self, request, item): + """ + TODO: must override this until wuttaweb has proper user auth checks + """ + perm = item.get('perm') + if perm: + return request.has_perm(perm) + return True + + def _make_raw_menus(self, request, **kwargs): + """ + We are overriding this to allow for making dynamic menus from + config/settings. Which may or may not be a good idea.. + """ + # 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: + + # 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') + + # okay, no config, so menus will be built from code + return self.make_menus(request, **kwargs) + + def _make_menus_from_config(self, request, **kwargs): + """ + Try to build a complete menu set from config/settings. + + 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 + + model = self.app.model + menus = [] + + # menu definition can come either from config file or db + # settings, but if the latter then we want to optimize with + # one big query + if self.config.getbool('tailbone.menu', 'from_settings', + default=False): + + # 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)) + + else: # read from config file only + for key in main_keys: + menus.append(self._make_single_menu_from_config(request, key)) + + 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': [], + } + + # title + title = self.config.get('tailbone.menu', + 'menu.{}.label'.format(key), + usedb=False) + menu['title'] = title or prettify(key) + + # items + item_keys = self.config.getlist('tailbone.menu', + 'menu.{}.items'.format(key), + usedb=False) + for item_key in item_keys: + item = {} + + if item_key == 'SEP': + item['type'] = 'sep' + + 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) + + # 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: + + # 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 0879025b..5c45f390 100644 --- a/tailbone/progress.py +++ b/tailbone/progress.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -27,34 +27,56 @@ 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(object): +class SessionProgress(ProgressBase): """ Provides a session-based progress bar mechanism. 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() @@ -82,6 +104,3 @@ class SessionProgress(object): self.session['value'] = value self.session.save() return not self.canceled - - def destroy(self): - pass 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 f3cd2ebe..0fa02dbb 100644 --- a/tailbone/static/css/base.css +++ b/tailbone/static/css/base.css @@ -1,121 +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; -} - -div.error { - color: #dd6666; - font-weight: bold; - margin-bottom: 10px; -} - -ul.error { - color: #dd6666; - font-weight: bold; - padding: 0px; -} - -ul.error li { - list-style-type: none; -} - -/****************************** - * 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/codehilite.css b/tailbone/static/css/codehilite.css new file mode 100644 index 00000000..a0992759 --- /dev/null +++ b/tailbone/static/css/codehilite.css @@ -0,0 +1,74 @@ +pre { line-height: 125%; } +td.linenos pre { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; } +span.linenos { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; } +td.linenos pre.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.codehilite .hll { background-color: #ffffcc } +.codehilite { background: #f8f8f8; } +.codehilite .c { color: #408080; font-style: italic } /* Comment */ +.codehilite .err { border: 1px solid #FF0000 } /* Error */ +.codehilite .k { color: #008000; font-weight: bold } /* Keyword */ +.codehilite .o { color: #666666 } /* Operator */ +.codehilite .ch { color: #408080; font-style: italic } /* Comment.Hashbang */ +.codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */ +.codehilite .cp { color: #BC7A00 } /* Comment.Preproc */ +.codehilite .cpf { color: #408080; font-style: italic } /* Comment.PreprocFile */ +.codehilite .c1 { color: #408080; font-style: italic } /* Comment.Single */ +.codehilite .cs { color: #408080; font-style: italic } /* Comment.Special */ +.codehilite .gd { color: #A00000 } /* Generic.Deleted */ +.codehilite .ge { font-style: italic } /* Generic.Emph */ +.codehilite .gr { color: #FF0000 } /* Generic.Error */ +.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.codehilite .gi { color: #00A000 } /* Generic.Inserted */ +.codehilite .go { color: #888888 } /* Generic.Output */ +.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.codehilite .gs { font-weight: bold } /* Generic.Strong */ +.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.codehilite .gt { color: #0044DD } /* Generic.Traceback */ +.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.codehilite .kp { color: #008000 } /* Keyword.Pseudo */ +.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.codehilite .kt { color: #B00040 } /* Keyword.Type */ +.codehilite .m { color: #666666 } /* Literal.Number */ +.codehilite .s { color: #BA2121 } /* Literal.String */ +.codehilite .na { color: #7D9029 } /* Name.Attribute */ +.codehilite .nb { color: #008000 } /* Name.Builtin */ +.codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.codehilite .no { color: #880000 } /* Name.Constant */ +.codehilite .nd { color: #AA22FF } /* Name.Decorator */ +.codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */ +.codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ +.codehilite .nf { color: #0000FF } /* Name.Function */ +.codehilite .nl { color: #A0A000 } /* Name.Label */ +.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.codehilite .nv { color: #19177C } /* Name.Variable */ +.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.codehilite .w { color: #bbbbbb } /* Text.Whitespace */ +.codehilite .mb { color: #666666 } /* Literal.Number.Bin */ +.codehilite .mf { color: #666666 } /* Literal.Number.Float */ +.codehilite .mh { color: #666666 } /* Literal.Number.Hex */ +.codehilite .mi { color: #666666 } /* Literal.Number.Integer */ +.codehilite .mo { color: #666666 } /* Literal.Number.Oct */ +.codehilite .sa { color: #BA2121 } /* Literal.String.Affix */ +.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */ +.codehilite .sc { color: #BA2121 } /* Literal.String.Char */ +.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */ +.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */ +.codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ +.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */ +.codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ +.codehilite .sx { color: #008000 } /* Literal.String.Other */ +.codehilite .sr { color: #BB6688 } /* Literal.String.Regex */ +.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */ +.codehilite .ss { color: #19177C } /* Literal.String.Symbol */ +.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */ +.codehilite .fm { color: #0000FF } /* Name.Function.Magic */ +.codehilite .vc { color: #19177C } /* Name.Variable.Class */ +.codehilite .vg { color: #19177C } /* Name.Variable.Global */ +.codehilite .vi { color: #19177C } /* Name.Variable.Instance */ +.codehilite .vm { color: #19177C } /* Name.Variable.Magic */ +.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */ diff --git a/tailbone/static/css/diffs.css b/tailbone/static/css/diffs.css index 81a4c8e7..662aae07 100644 --- a/tailbone/static/css/diffs.css +++ b/tailbone/static/css/diffs.css @@ -4,6 +4,14 @@ table.diff { border-collapse: collapse; border-left: 1px solid Black; border-top: 1px solid Black; + font-size: 11pt; + /* margin-top: 2em; */ + /* margin-left: 50px; */ + min-width: 80%; +} + +.ui-dialog-content table.diff { + color: black; } table.diff th, @@ -13,16 +21,22 @@ table.diff td { padding: 2px 5px; } +table.diff td { + padding: 5px 10px; +} + table.diff.monospace td.old-value, table.diff.monospace td.new-value{ font-family: monospace; white-space: pre; } -table.diff tr.diff td.new-value { +table.diff.new td.new-value, +table.diff.dirty tr.diff td.new-value { background-color: #cfc; } -table.diff tr.diff td.old-value { +table.diff.deleted td.old-value, +table.diff.dirty tr.diff td.old-value { background-color: #fcc; } 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 a08bf6d5..de4b1ebe 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -1,121 +1,61 @@ /****************************** - * Form Wrapper + * forms ******************************/ -div.form-wrapper { - overflow: auto; -} - - -/****************************** - * Context Menu - ******************************/ - -div.form-wrapper ul.context-menu { - float: right; - list-style-type: none; - margin: 0px; - text-align: right; -} - -div.form-wrapper ul.context-menu li { - line-height: 2em; -} - - -/****************************** - * 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 ******************************/ -div.field-wrapper { +/* TODO: replace this with bulma equivalent */ +.field-wrapper { clear: both; min-height: 30px; overflow: auto; - padding: 5px; + margin: 15px; } -div.field-wrapper.error { - background-color: #ddcccc; - border: 2px solid #dd6666; +/* TODO: replace this with bulma equivalent */ +.field-wrapper .field-row { + display: table-row; } -div.field-wrapper label { - display: block; - float: left; - width: 15em; +/* TODO: replace this with bulma equivalent */ +.field-wrapper label { + display: table-cell; + vertical-align: top; + width: 18em; font-weight: bold; - margin-top: 2px; + padding-top: 2px; white-space: nowrap; } -.field-wrapper.error label { - color: Black; -} - -div.field-wrapper div.field-error { - color: #dd6666; - font-weight: bold; -} - -div.field-wrapper div.field { - display: block; - float: left; - margin-bottom: 5px; +/* TODO: replace this with bulma equivalent */ +.field-wrapper .field { + display: table-cell; line-height: 25px; } - -div.field-wrapper div.field input[type=text], -div.field-wrapper div.field input[type=password], -div.field-wrapper div.field select, -div.field-wrapper div.field textarea { - width: 320px; -} - -label input[type="checkbox"], -label input[type="radio"] { - margin-right: 0.5em; -} - -div.field ul { - margin: 0px; - padding-left: 15px; -} - - -/****************************** - * Buttons - ******************************/ - -div.buttons { - clear: both; - margin: 10px 0px; -} - - -/****************************** - * Email Profile forms - ******************************/ - -.field-wrapper.description .field { - /* NOTE: This was added specifically for email settings (description), who - knows what else it breaks...hopefully nothing. */ - width: 800px; -} diff --git a/tailbone/static/css/grids.css b/tailbone/static/css/grids.css index 04cb867f..42da832c 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -22,10 +22,14 @@ } .grid-wrapper .grid-header #context-menu { - float: none; margin: 0; } +.grid-tools { + display: flex; + gap: 0.5rem; +} + .grid-wrapper .grid-header td.tools { margin: 0; padding: 0; @@ -240,11 +244,32 @@ text-align: center; } +.grid tr:not(.header).selected.odd { + background-color: #507aaa; +} + +.grid tr:not(.header).selected.even { + background-color: #628db6; +} + +.grid tr:not(.header).selected.hovering { + background-color: #3e5b76; + color: white; +} + +.grid tr:not(.header).selected a { + color: #cccccc; +} + /****************************** * main actions ******************************/ +a.grid-action { + white-space: nowrap; +} + .grid .actions { width: 1px; } diff --git a/tailbone/static/css/grids.rowstatus.css b/tailbone/static/css/grids.rowstatus.css new file mode 100644 index 00000000..bfd73404 --- /dev/null +++ b/tailbone/static/css/grids.rowstatus.css @@ -0,0 +1,49 @@ + +/******************************************************************************** + * grids.rowstatus.css + * + * Add "row status" styles for grid tables. + ********************************************************************************/ + +/************************************************** + * grid rows with "warning" status + **************************************************/ + +tr.warning { + background-color: #fcc; +} + + +/************************************************** + * grid rows with "notice" status + **************************************************/ + +tr.notice { + background-color: #fe8; +} + + +/************************************************** + * 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; +} 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 4fd44333..ef5c5352 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -1,243 +1,158 @@ /****************************** - * 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 h1.title { - font-size: 20px; - margin-left: 10px; -} - -#header div.login { - float: right; -} - -.global .title { - margin-left: 0.5em; -} - -/* 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 .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 { - float: right; - list-style-type: none; - margin: 0.5em; + margin-bottom: 1em; + margin-left: 1em; text-align: right; white-space: nowrap; } - /****************************** - * Panels + * "object helper" panel ******************************/ -.panel, -.panel-grid { - border-left: 1px solid Black; - margin-bottom: 15px; +.object-helpers .panel { + margin: 1rem; + margin-bottom: 1.5rem; } -.panel { - border-bottom: 1px solid Black; - border-right: 1px solid Black; - padding: 0px; +.object-helpers .panel-heading { + white-space: nowrap; } -.panel h2, -.panel-grid h2 { - border-bottom: 1px solid Black; - border-top: 1px solid Black; - padding: 5px; - margin: 0px; +.object-helpers a { + white-space: nowrap; } -.panel-grid h2 { - border-right: 1px solid Black; +.object-helper { + border: 1px solid black; + margin: 1em; + padding: 1em; + width: 20em; } -.panel-body { - overflow: auto; - padding: 5px; +.object-helper-content { + margin-top: 1em; } -/**************************************** - * footer - ****************************************/ +/****************************** + * markdown + ******************************/ -#footer { - border-top: 1px solid lightgray; - bottom: 0; - font-size: 9pt; - height: 20px; - left: 0; - line-height: 20px; - margin: 0; - position: absolute; - width: 100%; +.rendered-markdown p, +.rendered-markdown ul { + margin-bottom: 1rem; } +.rendered-markdown .codehilite { + margin-bottom: 2rem; +} + +/****************************** + * fix datepicker within modals + * TODO: someday this may not be necessary? cf. + * https://github.com/buefy/buefy/issues/292#issuecomment-347365637 + ******************************/ + +.modal .animation-content .modal-card { + overflow: visible !important; +} + +.modal-card-body { + overflow: visible !important; +} + +/* TODO: a simpler option we might try sometime instead? */ +/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */ + +/* .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 2603f961..00000000 --- a/tailbone/static/css/login.css +++ /dev/null @@ -1,40 +0,0 @@ - -/****************************** - * login.css - ******************************/ - -#logo { - margin: 40px auto; -} - -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 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/css/mobile.css b/tailbone/static/css/mobile.css deleted file mode 100644 index a3493f12..00000000 --- a/tailbone/static/css/mobile.css +++ /dev/null @@ -1,46 +0,0 @@ - -/**************************************** - * Global styles for mobile templates - ****************************************/ - -/* main user menu button when root */ -[data-role="header"] a.root-user, -[data-role="header"] a.root-user:hover { - background-color: red; -} - -/* become/stop root menu links */ -#usermenu .root-user a { - background-color: red; -} - -/* normal flash messages */ -.flash { - color: green; - margin-bottom: 1em; -} - -/* error flash messages */ -.error { - color: red; - margin-bottom: 1em; -} - -/* receiving warning flash messages */ -.receiving-warning { - color: red; -} - -.replacement-header { - display: none; -} - -.field-wrapper label { - font-weight: bold; - margin-top: 1em; -} - -/* make sure space comes between simple filter and "grid" list */ -.simple-filter { - margin-bottom: 1.5em; -} diff --git a/tailbone/static/css/perms.css b/tailbone/static/css/perms.css index 7db6a2c0..45cdcb15 100644 --- a/tailbone/static/css/perms.css +++ b/tailbone/static/css/perms.css @@ -3,34 +3,31 @@ * Permission Lists ******************************/ -div.field-wrapper.permissions { - overflow: visible; -} - -div.field-wrapper.permissions div.field div.group { +.permissions-group { margin-bottom: 10px; } -div.field-wrapper.permissions div.field div.group p { +.permissions-group .group-label { font-weight: bold; + margin-bottom: 10px; } -div.field-wrapper.permissions div.field label { - float: none; +.field-wrapper.permissions .field label { + display: inline; font-weight: normal; } -div.field-wrapper.permissions div.field label input { +.field-wrapper.permissions .field label input { margin-left: 15px; margin-right: 10px; } -div.field-wrapper.permissions div.field div.group p.perm { +.permissions-group .perm { font-weight: normal; margin-left: 15px; } -div.field-wrapper.permissions div.field div.group p.perm span { +.permissions-group p.perm span { font-family: monospace; margin-right: 10px; } 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/debounce.js b/tailbone/static/js/debounce.js new file mode 100644 index 00000000..8fea0eda --- /dev/null +++ b/tailbone/static/js/debounce.js @@ -0,0 +1,36 @@ + +// this code was politely stolen from +// https://vanillajstoolkit.com/helpers/debounce/ + +// its purpose is to help with Buefy autocomplete performance +// https://buefy.org/documentation/autocomplete/ + +/** + * Debounce functions for better performance + * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com + * @param {Function} fn The function to debounce + */ +function debounce (fn) { + + // Setup a timer + let timeout; + + // Return a function to run debounced + return function () { + + // Setup the arguments + let context = this; + let args = arguments; + + // If there's a timer, cancel it + if (timeout) { + window.cancelAnimationFrame(timeout); + } + + // Setup the new requestAnimationFrame() + timeout = window.requestAnimationFrame(function () { + fn.apply(context, args); + }); + + }; +} diff --git a/tailbone/static/js/jquery.ui.tailbone.js b/tailbone/static/js/jquery.ui.tailbone.js deleted file mode 100644 index a5a4dadc..00000000 --- a/tailbone/static/js/jquery.ui.tailbone.js +++ /dev/null @@ -1,349 +0,0 @@ -// -*- coding: utf-8 -*- -/********************************************************************** - * jQuery UI plugins for Tailbone - **********************************************************************/ - -/********************************************************************** - * 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'); - - // 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'); - } - }); - - // 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; - }); - - // Add hover highlight effect to grid rows during mouse-over. - this.element.on('mouseenter', 'tbody tr:not(.header)', function() { - $(this).addClass('hovering'); - }); - this.element.on('mouseleave', 'tbody tr:not(.header)', function() { - $(this).removeClass('hovering'); - }); - - // do some extra stuff for grids with checkboxes - - // (un-)check all rows when clicking check-all box in header - if (this.grid.find('tr.header td.checkbox input').length) { - this.element.on('click', 'tr.header td.checkbox input', function() { - var checked = $(this).prop('checked'); - that.grid.find('tr:not(.header) td.checkbox input').prop('checked', checked); - }); - - } - - // Select current row when clicked, unless clicking checkbox - // (since that already does select the row) or a link (since - // that does something completely different). - this.element.on('click', '.grid tr:not(.header) td.checkbox input', function(event) { - event.stopPropagation(); - }); - this.element.on('click', '.grid tr:not(.header) a', function(event) { - event.stopPropagation(); - }); - this.element.on('click', '.grid tr:not(.header)', function() { - $(this).find('td.checkbox input').click(); - }); - - // Show 'more' actions when user hovers over 'more' link. - this.element.on('mouseenter', '.actions a.more', function() { - that.grid.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.grid.data('delete-speedbump')) { - this.element.on('click', 'tbody td.actions a.delete', function() { - return confirm("Are you sure you wish to delete this object?"); - }); - } - }, - - // 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.element.unmask(); - }); - } - - }); - -})( 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(); - } - } - }); - - // Enhance any date values with datepicker widget. - this.inputs.find('.value input[type="date"]').datepicker({ - dateFormat: 'yy-mm-dd', - changeYear: true, - changeMonth: true - }); - - // Enhance any choice/dropdown values with selectmenu. - this.inputs.find('.value select').selectmenu({ - width: '15em' - }); - - // 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/jquery.ui.tailbone.mobile.js b/tailbone/static/js/jquery.ui.tailbone.mobile.js deleted file mode 100644 index 33a2a7be..00000000 --- a/tailbone/static/js/jquery.ui.tailbone.mobile.js +++ /dev/null @@ -1,78 +0,0 @@ - -/****************************************** - * jQuery Mobile plugins for Tailbone - *****************************************/ - -/****************************************** - * mobile autocomplete - *****************************************/ - -(function($) { - - $.widget('tailbone.mobileautocomplete', { - - _create: function() { - var that = this; - - // snag some element references - this.search = this.element.find('.ui-input-search'); - this.hidden_field = this.element.find('input[type="hidden"]'); - this.text_field = this.element.find('input[type="text"]'); - this.ul = this.element.find('ul'); - this.button = this.element.find('button'); - - // establish our autocomplete URL - this.url = this.options.url || this.element.data('url'); - - // NOTE: much of this code was copied from the jquery mobile demo site - // https://demos.jquerymobile.com/1.4.5/listview-autocomplete-remote/ - this.ul.on('filterablebeforefilter', function(e, data) { - - var $input = $( data.input ), - value = $input.val(), - html = ""; - that.ul.html( "" ); - if ( value && value.length > 2 ) { - that.ul.html( "<li><div class='ui-loader'><span class='ui-icon ui-icon-loading'></span></div></li>" ); - that.ul.listview( "refresh" ); - $.ajax({ - url: that.url, - data: { - term: $input.val() - } - }) - .then( function ( response ) { - $.each( response, function ( i, val ) { - html += '<li data-uuid="' + val.value + '">' + val.label + "</li>"; - }); - that.ul.html( html ); - that.ul.listview( "refresh" ); - that.ul.trigger( "updatelayout"); - }); - } - - }); - - // when user clicks autocomplete result, hide search etc. - this.ul.on('click', 'li', function() { - var $li = $(this); - that.search.hide(); - that.hidden_field.val($li.data('uuid')); - that.button.text($li.text()).show(); - that.ul.hide(); - }); - - // when user clicks "change" button, show search etc. - this.button.click(function() { - that.button.hide(); - that.ul.empty().show(); - that.hidden_field.val(''); - that.search.show(); - that.text_field.focus(); - }); - - } - - }); - -})( 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 f0794734..00000000 --- a/tailbone/static/js/login.js +++ /dev/null @@ -1,32 +0,0 @@ - -$(function() { - - $('#username').keydown(function(event) { - if (event.which == 13) { - $('#password').focus().select(); - return false; - } - return true; - }); - - $('form').submit(function() { - if (! $('#username').val()) { - with ($('#username').get(0)) { - select(); - focus(); - } - return false; - } - if (! $('#password').val()) { - with ($('#password').get(0)) { - select(); - focus(); - } - return false; - } - return true; - }); - - $('#username').focus(); - -}); diff --git a/tailbone/static/js/tailbone.batch.js b/tailbone/static/js/tailbone.batch.js deleted file mode 100644 index c3fd30bc..00000000 --- a/tailbone/static/js/tailbone.batch.js +++ /dev/null @@ -1,44 +0,0 @@ - -/************************************************************ - * - * tailbone.batch.js - * - * Common logic for view/edit batch pages - * - ************************************************************/ - - -$(function() { - - $('.grid-wrapper').gridwrapper(); - - $('#execute-batch').click(function() { - if (has_execution_options) { - $('#execution-options-dialog').dialog({ - title: "Execution Options", - width: 500, - height: 300, - modal: true, - buttons: [ - { - text: "Execute", - click: function(event) { - $(event.target).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 new file mode 100644 index 00000000..b4070fab --- /dev/null +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -0,0 +1,235 @@ + +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, + + // 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 = { + value: this.value, + 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, + + // 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: { + // // 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: { + + // 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 + } + + // and perform the search + this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry)) + .then(({ data }) => { + this.data = data + }) + .catch((error) => { + this.data = [] + throw error + }) + }), + + // 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 + }, + }, +} + +Vue.component('tailbone-autocomplete', TailboneAutocomplete) diff --git a/tailbone/static/js/tailbone.buefy.datepicker.js b/tailbone/static/js/tailbone.buefy.datepicker.js new file mode 100644 index 00000000..0b861fd6 --- /dev/null +++ b/tailbone/static/js/tailbone.buefy.datepicker.js @@ -0,0 +1,77 @@ + +const TailboneDatepicker = { + + template: [ + '<b-datepicker', + 'placeholder="Click to select ..."', + `:name="name"`, + `:id="id"`, + 'editable', + 'icon-pack="fas"', + 'icon="calendar-alt"', + ':date-formatter="formatDate"', + ':date-parser="parseDate"', + ':value="buefyValue"', + '@input="dateChanged"', + ':disabled="disabled"', + 'ref="trueDatePicker"', + '>', + '</b-datepicker>' + ].join(' '), + + props: { + name: String, + id: String, + value: String, + disabled: Boolean, + }, + + data() { + return { + buefyValue: this.parseDate(this.value), + } + }, + + watch: { + value(to, from) { + this.buefyValue = this.parseDate(to) + }, + }, + + methods: { + + formatDate(date) { + if (date === null) { + return null + } + // 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(date) { + if (typeof(date) == 'string') { + // note, this assumes classic YYYY-MM-DD (i.e. ISO?) format + var parts = date.split('-') + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]) + } + return date + }, + + dateChanged(date) { + this.$emit('input', this.formatDate(date)) + }, + + focus() { + this.$refs.trueDatePicker.focus() + }, + } + +} + +Vue.component('tailbone-datepicker', TailboneDatepicker) diff --git a/tailbone/static/js/tailbone.buefy.message_recipients.js b/tailbone/static/js/tailbone.buefy.message_recipients.js new file mode 100644 index 00000000..161ad404 --- /dev/null +++ b/tailbone/static/js/tailbone.buefy.message_recipients.js @@ -0,0 +1,108 @@ + +const MessageRecipients = { + template: '#message-recipients-template', + + props: { + name: String, + value: Array, + possibleRecipients: Array, + recipientDisplayMap: Object, + }, + + data() { + return { + autocompleteValue: null, + actualValue: this.value, + } + }, + + computed: { + + filteredData() { + // this is the logic responsible for "matching" user's autocomplete + // input, with possible recipients. we return all matches as list. + let filtered = [] + if (this.autocompleteValue) { + let term = this.autocompleteValue.toLowerCase() + this.possibleRecipients.forEach(function(value, key, map) { + + // first check to see if value is simple string, if so then + // will attempt to match it directly + if (value.toLowerCase !== undefined) { + if (value.toLowerCase().indexOf(term) >= 0) { + filtered.push({value: key, label: value}) + } + + } else { + // value is not a string, which means it must be a + // grouping object, which must have a name property + if (value.name.toLowerCase().indexOf(term) >= 0) { + filtered.push({ + value: key, + label: value.name, + moreValues: value.uuids, + }) + } + } + }) + } + return filtered + }, + }, + + methods: { + + addRecipient(uuid) { + + // add selected user to "actual" value + if (!this.actualValue.includes(uuid)) { + this.actualValue.push(uuid) + } + }, + + removeRecipient(uuid) { + + // locate and remove user uuid from "actual" value + for (let i = 0; i < this.actualValue.length; i++) { + if (this.actualValue[i] == uuid) { + this.actualValue.splice(i, 1) + break + } + } + }, + + selectionMade(option) { + + // apparently option can be null sometimes..? + if (option) { + + // add all newly-selected users to "actual" value + if (option.moreValues) { + // grouping object; add all its "contained" values + option.moreValues.forEach(function(uuid) { + this.addRecipient(uuid) + }, this) + } else { + // normal object, just add its value + this.addRecipient(option.value) + } + + // let parent know we changed value + this.$emit('input', this.actualValue) + } + + // clear out the *visible* autocomplete value + this.$nextTick(function() { + this.autocompleteValue = null + + // TODO: wtf, sometimes we have to clear this out twice?! + this.$nextTick(function() { + this.autocompleteValue = null + }) + }) + }, + }, +} + + +Vue.component('message-recipients', MessageRecipients) diff --git a/tailbone/static/js/tailbone.buefy.numericinput.js b/tailbone/static/js/tailbone.buefy.numericinput.js new file mode 100644 index 00000000..b2f2ac0c --- /dev/null +++ b/tailbone/static/js/tailbone.buefy.numericinput.js @@ -0,0 +1,67 @@ + +const NumericInput = { + template: [ + '<b-input', + ':name="name"', + ':value="value"', + 'ref="input"', + ':placeholder="placeholder"', + ':size="size"', + ':icon-pack="iconPack"', + ':icon="icon"', + ':disabled="disabled"', + '@focus="notifyFocus"', + '@blur="notifyBlur"', + '@keydown.native="keyDown"', + '@input="valueChanged"', + '>', + '</b-input>' + ].join(' '), + + props: { + name: String, + value: [Number, String], + placeholder: String, + iconPack: String, + icon: String, + size: String, + disabled: Boolean, + allowEnter: Boolean + }, + + methods: { + + focus() { + this.$refs.input.focus() + }, + + notifyFocus(event) { + this.$emit('focus', event) + }, + + notifyBlur(event) { + this.$emit('blur', event) + }, + + 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() + } + } + }, + + select() { + this.$el.children[0].select() + }, + + valueChanged(value) { + this.$emit('input', value) + } + + } +} + +Vue.component('numeric-input', NumericInput) diff --git a/tailbone/static/js/tailbone.buefy.oncebutton.js b/tailbone/static/js/tailbone.buefy.oncebutton.js new file mode 100644 index 00000000..e00d677c --- /dev/null +++ b/tailbone/static/js/tailbone.buefy.oncebutton.js @@ -0,0 +1,69 @@ + +const OnceButton = { + + 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> + `, + + 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) + }) + } + } + +} + +Vue.component('once-button', OnceButton) diff --git a/tailbone/static/js/tailbone.buefy.timepicker.js b/tailbone/static/js/tailbone.buefy.timepicker.js new file mode 100644 index 00000000..207a7940 --- /dev/null +++ b/tailbone/static/js/tailbone.buefy.timepicker.js @@ -0,0 +1,63 @@ + +const TailboneTimepicker = { + + template: [ + '<b-timepicker', + ':name="name"', + ':id="id"', + 'editable', + 'placeholder="Click to select ..."', + 'icon-pack="fas"', + 'icon="clock"', + ':value="value ? parseTime(value) : null"', + 'hour-format="12"', + '@input="timeChanged"', + ':time-formatter="formatTime"', + '>', + '</b-timepicker>' + ].join(' '), + + props: { + name: String, + id: String, + value: String, + }, + + methods: { + + formatTime(time) { + if (time === null) { + return null + } + + let h = time.getHours() + let m = time.getMinutes() + let s = time.getSeconds() + + h = h < 10 ? '0' + h : h + m = m < 10 ? '0' + m : m + s = s < 10 ? '0' + s : s + + return h + ':' + m + ':' + s + }, + + parseTime(time) { + + if (time.getHours) { + return time + } + + let found = time.match(/^(\d\d):(\d\d):\d\d$/) + if (found) { + return new Date(null, null, null, + parseInt(found[1]), parseInt(found[2])) + } + }, + + timeChanged(time) { + this.$emit('input', time) + }, + }, +} + +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 a9412949..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: 500, - 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 91b3c229..00000000 --- a/tailbone/static/js/tailbone.js +++ /dev/null @@ -1,347 +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 = "Working, please wait..."; - } - if (label) { - $(button).button('option', 'label', 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; -} - - -/* - * 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() { - - /* - * Initialize the menu bar. - */ - $('ul.menubar').menubar({ - buttons: true, - menuIcon: true, - autoExpand: true - }); - - /* - * enhance buttons - */ - $('button, a.button').button(); - $('input[type=submit]').button(); - $('input[type=reset]').button(); - $('a.button.autodisable').click(function() { - disable_button(this); - }); - // 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 - $('form.autodisable').submit(function() { - var submit = $(this).find('input[type="submit"]'); - if (submit.length) { - disable_button(submit); - } - }); - - /* - * enhance dropdowns - */ - $('select[auto-enhance="true"]').selectmenu(); - - /* 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.mobile.js b/tailbone/static/js/tailbone.mobile.js deleted file mode 100644 index 35d691e5..00000000 --- a/tailbone/static/js/tailbone.mobile.js +++ /dev/null @@ -1,237 +0,0 @@ - -/************************************************************ - * - * tailbone.mobile.js - * - * Global logic for mobile app - * - ************************************************************/ - - -$(function() { - - // must init header/footer toolbars since ours are "external" - $('[data-role="header"], [data-role="footer"]').toolbar({theme: 'a'}); -}); - - -$(document).on('pagecontainerchange', function(event, ui) { - - // in some cases (i.e. when no user is logged in) we may want the (external) - // header toolbar button to change between pages. here's how we do that. - // note however that we do this *always* even when not technically needed - var link = $('[data-role="header"] a'); - var newlink = ui.toPage.find('.replacement-header a'); - link.text(newlink.text()); - link.attr('href', newlink.attr('href')); - link.removeClass('ui-icon-home ui-icon-user'); - link.addClass(newlink.attr('class')); -}); - - -$(document).on('pagecreate', function() { - - // setup any autocomplete fields - $('.field.autocomplete').mobileautocomplete(); - -}); - - -/** - * Automatically set focus to certain fields, on various pages - * TODO: this should accept selector params instead of hard-coding..? - */ -function setfocus() { - var el = null; - var queries = [ - '#username', - '#new-purchasing-batch-vendor-text', - // '.receiving-upc-search', - ]; - $.each(queries, function(i, query) { - el = $(query); - if (el.is(':visible')) { - el.focus(); - return false; - } - }); -} - - -$(document).on('pageshow', function() { - - setfocus(); - - // TODO: seems like this should be better somehow... - // remove all flash messages after 2.5 seconds - window.setTimeout(function() { $('.flash, .error').remove(); }, 2500); - -}); - - -// handle radio button value change for "simple" grid filter -$(document).on('change', '.simple-filter .ui-radio', function() { - $(this).parents('form:first').submit(); -}); - - -// vendor validation for new purchasing batch -$(document).on('click', 'form[name="new-purchasing-batch"] input[type="submit"]', function() { - var $form = $(this).parents('form'); - if (! $form.find('[name="vendor"]').val()) { - alert("Please select a vendor"); - $form.find('[name="new-purchasing-batch-vendor-text"]').focus(); - return false; - } -}); - -// submit new purchasing batch form on Purchase click -$(document).on('click', 'form[name="new-purchasing-batch"] [data-role="listview"] a', function() { - var $form = $(this).parents('form'); - var $field = $form.find('[name="purchase"]'); - var uuid = $(this).parents('li').data('uuid'); - $field.val(uuid); - $form.submit(); - return false; -}); - - -// disable datasync restart button when clicked -$(document).on('click', '#datasync-restart', function() { - $(this).button('disable'); -}); - - -// handle global keypress on product batch "row" page, for sake of scanner wedge -var product_batch_routes = [ - 'mobile.batch.inventory.view', - 'mobile.receiving.view', -]; -$(document).on('keypress', function(event) { - var current_route = $('.ui-page-active [role="main"]').data('route'); - for (var route of product_batch_routes) { - if (current_route == route) { - var upc = $('.ui-page-active #upc-search'); - if (upc.length) { - if (upc.is(':focus')) { - if (event.which == 13) { - if (upc.val()) { - $.mobile.navigate(upc.data('url') + '?upc=' + upc.val()); - } - } - } else { - if (event.which >= 48 && event.which <= 57) { // numeric (qwerty) - upc.val(upc.val() + event.key); - // TODO: these codes are correct for 'keydown' but apparently not 'keypress' ? - // } else if (event.which >= 96 && event.which <= 105) { // numeric (10-key) - // upc.val(upc.val() + event.key); - } else if (event.which == 13) { - if (upc.val()) { - $.mobile.navigate(upc.data('url') + '?upc=' + upc.val()); - } - } - return false; - } - } - } - } -}); - - -// when numeric keypad button is clicked, update quantity accordingly -$(document).on('click', '.quantity-keypad-thingy .keypad-button', function() { - var keypad = $(this).parents('.quantity-keypad-thingy'); - var quantity = keypad.find('.keypad-quantity'); - var value = quantity.text(); - var key = $(this).text(); - var changed = keypad.data('changed'); - if (key == 'Del') { - if (value.length == 1) { - quantity.text('0'); - } else { - quantity.text(value.substring(0, value.length - 1)); - } - changed = true; - } else if (key == '.') { - if (value.indexOf('.') == -1) { - if (changed) { - quantity.text(value + '.'); - } else { - quantity.text('0.'); - changed = true; - } - } - } else { - if (value == '0') { - quantity.text(key); - changed = true; - } else if (changed) { - quantity.text(value + key); - } else { - quantity.text(key); - changed = true; - } - } - if (changed) { - keypad.data('changed', true); - } -}); - - -// show/hide expiration date per receiving mode selection -$(document).on('change', 'fieldset.receiving-mode input[name="mode"]', function() { - var mode = $(this).val(); - if (mode == 'expired') { - $('#expiration-row').show(); - } else { - $('#expiration-row').hide(); - } -}); - - -// handle receiving action buttons -$(document).on('click', '.receiving-actions button', function() { - var action = $(this).data('action'); - var form = $(this).parents('form:first'); - var uom = form.find('[name="keypad-uom"]:checked').val(); - var mode = form.find('[name="mode"]:checked').val(); - var qty = form.find('.keypad-quantity').text(); - if (action == 'add' || action == 'subtract') { - if (qty != '0') { - if (action == 'subtract') { - qty = '-' + qty; - } - - if (uom == 'CS') { - form.find('[name="cases"]').val(qty); - } else { // units - form.find('[name="units"]').val(qty); - } - - if (action == 'add' && mode == 'expired') { - var expiry = form.find('input[name="expiration_date"]'); - if (! /^\d{4}-\d{2}-\d{2}$/.test(expiry.val())) { - alert("Please enter a valid expiration date."); - expiry.focus(); - return; - } - } - - form.submit(); - } - } -}); - - -// handle inventory save button -$(document).on('click', '.inventory-actions button.save', function() { - var form = $(this).parents('form:first'); - var uom = form.find('[name="keypad-uom"]:checked').val(); - var qty = form.find('.keypad-quantity').text(); - if (uom == 'CS') { - form.find('input[name="cases"]').val(qty); - } else { // units - form.find('input[name="units"]').val(qty); - } - form.submit(); -}); 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/subscribers.py b/tailbone/subscribers.py index 8694b421..268d4818 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.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,64 +24,192 @@ Event Subscribers """ -from __future__ import unicode_literals, absolute_import - -import six +import datetime +import logging +import warnings +from collections import OrderedDict import rattail -from rattail.db import model -from rattail.db.auth import has_permission +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, should_expose_websockets +from tailbone.util import get_available_themes, get_global_search_options -def add_rattail_config_attribute_to_request(event): +log = logging.getLogger(__name__) + + +def new_request(event, session=None): """ - Add a ``rattail_config`` attribute to a request object. + Event hook called when processing a new request. - This function is really just a matter of convenience, but it should help to - make other code more terse (example below). It is designed to act as a - subscriber to the Pyramid ``NewRequest`` event. + This first invokes the upstream hooks: - A global Rattail ``config`` should already be present within the Pyramid - application registry's settings, which would normally be accessed via:: - - request.registry.settings['rattail_config'] + * :func:`wuttaweb:wuttaweb.subscribers.new_request()` + * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()` - This function merely "promotes" this config object so that it is more - directly accessible, a la:: + It then adds more things to the request object; among them: - request.rattail_config + .. attribute:: request.rattail_config - .. note:: - All 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. + 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') - if rattail_config: - request.rattail_config = rattail_config + + # 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 + if hasattr(request, 'client_addr'): + session.continuum_remote_addr = request.client_addr + + # request.register_component() + def register_component(tagname, classname): + """ + Register a Vue 3 component, so the base template knows to + declare it for use within the app (page). + """ + if not hasattr(request, '_tailbone_registered_components'): + request._tailbone_registered_components = OrderedDict() + + 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 + + # misc. + renderer_globals['datetime'] = datetime + renderer_globals['colander'] = colander + renderer_globals['deform'] = deform + renderer_globals['csrf_header_name'] = csrf_header_name(config) + + # TODO: deprecate / remove these + renderer_globals['rattail_app'] = app + renderer_globals['app_title'] = app.get_title() + renderer_globals['app_version'] = app.get_version() renderer_globals['rattail'] = rattail renderer_globals['tailbone'] = tailbone - renderer_globals['enum'] = request.rattail_config.get_enum() - renderer_globals['six'] = six + 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 = config.get_bool('tailbone.themes.expose_picker', + default=False) + renderer_globals['expose_theme_picker'] = expose_picker + if expose_picker: + + # TODO: should remove 'falafel' option altogether + available = get_available_themes(config) + + options = [tags.Option(theme, value=theme) for theme in available] + renderer_globals['theme_picker_options'] = options + + # TODO: ugh, same deal here + 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'] = config.get('tailbone.background_color') + + # maybe set custom stylesheet + css = None + if request.user: + css = config.get(f'tailbone.{request.user.uuid}', 'user_css') + if not css: + css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css') + if css: + warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be" + f"changed to 'tailbone.{request.user.uuid}.user_css'", + DeprecationWarning) + renderer_globals['user_css'] = css + + # add global search data for quick access + renderer_globals['global_search_data'] = get_global_search_options(request) + + # here we globally declare widths for grid filter pseudo-columns + widths = config.get('tailbone.grids.filters.column_widths') + if widths: + widths = widths.split(';') + if len(widths) < 2: + widths = None + if not widths: + widths = ['15em', '15em'] + 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): @@ -95,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)\ @@ -105,58 +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 - request.user = None - uuid = request.authenticated_userid - if uuid: - request.user = Session.query(model.User).get(uuid) - if request.user: - Session().set_continuum_user(request.user) - - request.is_admin = bool(request.user) and request.user.is_admin() - request.is_root = request.is_admin and request.session.get('is_root', False) - - def has_perm(name): - if has_permission(Session(), request.user, name): - 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): - 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)): - referrer = default or request.route_url('home') - return referrer - request.get_referrer = get_referrer - def get_session_timeout(): """ Returns the timeout in effect for the current session @@ -166,6 +252,6 @@ def context_found(event): def includeme(config): - config.add_subscriber(add_rattail_config_attribute_to_request, 'pyramid.events.NewRequest') + config.add_subscriber(new_request, 'pyramid.events.NewRequest') config.add_subscriber(before_render, 'pyramid.events.BeforeRender') config.add_subscriber(context_found, 'pyramid.events.ContextFound') diff --git a/tailbone/templates/about.mako b/tailbone/templates/about.mako index 20ac3428..ef27c19c 100644 --- a/tailbone/templates/about.mako +++ b/tailbone/templates/about.mako @@ -1,13 +1,19 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> -<%def name="title()">About ${self.app_title()}</%def> +<%def name="title()">About ${base_meta.app_title()}</%def> -<h2>${project_title} ${project_version}</h2> +<%def name="page_content()"> + <h2>${project_title} ${project_version}</h2> -% for name, version in packages.iteritems(): - <h3>${name} ${version}</h3> -% endfor + % for name, version in packages.items(): + <h3>${name} ${version}</h3> + % endfor -<br /> -<p>Please see <a href="https://rattailproject.org/">rattailproject.org</a> for more info.</p> + <br /> + <p>Please see <a href="https://rattailproject.org/">rattailproject.org</a> for more info.</p> +</%def> + + +${parent.body()} 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 new file mode 100644 index 00000000..ba667e0e --- /dev/null +++ b/tailbone/templates/appsettings.mako @@ -0,0 +1,194 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">App Settings</%def> + +<%def name="content_title()"></%def> + +<%def name="context_menu_items()"> + % if request.has_perm('settings.list'): + <li>${h.link_to("View Raw Settings", url('settings'))}</li> + % endif +</%def> + +<%def name="page_content()"> + <app-settings :groups="groups" :showing-group="showingGroup"></app-settings> +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + <script type="text/x-template" id="app-settings-template"> + + <div class="form"> + ${h.form(form.action_url, id=dform.formid, method='post', **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} + + % if dform.error: + <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="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" + class="card" + v-show="!showingGroup || showingGroup == group.label" + style="margin-bottom: 1rem;"> + <header class="card-header"> + <p class="card-header-title">{{ group.label }}</p> + </header> + <div class="card-content"> + <div v-for="setting in group.settings" + ## TODO: not sure how the error handling looks now? + ## :class="'field-wrapper' + (setting.error ? ' with-error' : '')" + > + + <div style="margin-bottom: 2rem;"> + + <b-field horizontal + :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" + :id="setting.field_name" + v-model="setting.value" + native-value="true"> + {{ setting.value || false }} + </b-checkbox> + + <b-input v-else-if="setting.data_type == 'list'" + type="textarea" + :name="setting.field_name" + v-model="setting.value"> + </b-input> + + <b-select v-else-if="setting.choices" + :name="setting.field_name" + :id="setting.field_name" + v-model="setting.value"> + <option v-for="choice in setting.choices" + :value="choice"> + {{ choice }} + </option> + </b-select> + + <b-input v-else + :name="setting.field_name" + :id="setting.field_name" + v-model="setting.value" /> + + </b-field> + + <span v-if="setting.helptext" + v-html="setting.helptext" + class="instructions"> + </span> + </div> + + </div> + </div><!-- card-content --> + </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> + </div> + + </div><!-- app-wrapper --> + + ${h.end_form()} + </div> + </script> +</%def> + + +<%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_vue_components()"> + ${parent.make_vue_components()} + <script> + + Vue.component('app-settings', { + template: '#app-settings-template', + props: { + groups: Array, + showingGroup: String + }, + data() { + 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> diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index 249f8f2e..4c413757 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -1,58 +1,29 @@ -## -*- 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> +## -*- coding: utf-8; -*- + +<%def name="tailbone_autocomplete_template()"> + <script type="text/x-template" id="tailbone-autocomplete-template"> + <div> + + <b-autocomplete ref="autocomplete" + :name="name" + v-show="!value && !selected" + v-model="buefyValue" + :placeholder="placeholder" + :data="data" + @typing="getAsyncData" + @select="selectionMade" + keep-first> + <template slot-scope="props"> + {{ props.option.label }} + </template> + </b-autocomplete> + + <b-button v-if="value || selected" + style="width: 100%; justify-content: left;" + @click="clearSelection(true)"> + {{ getDisplayText() }} (click to change) + </b-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> diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 7c138f6d..8228f823 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,18 +1,33 @@ ## -*- 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> <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> - <title>${self.global_title()} » ${capture(self.title)|n}</title> - ${self.favicon()} + <title>${base_meta.global_title()} » ${capture(self.title)|n}</title> + ${base_meta.favicon()} ${self.header_core()} + % 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 { 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 @@ -20,159 +35,878 @@ </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')}"> - ${self.header_logo()} - <span class="title">${self.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 - % endif + ## content body from derived/child template + ${self.body()} - <div class="feedback"> - <button type="button" id="feedback">Feedback</button> - </div> - - </div><!-- global --> - - <div class="page"> - ${self.content_title()} - </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"> - ${self.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> -<%def name="app_title()">Rattail</%def> - -<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def> - <%def name="title()"></%def> <%def name="content_title()"> - <h1>${self.title()}</h1> + ${self.title()} </%def> -<%def name="favicon()"></%def> - <%def name="header_core()"> + ${self.core_javascript()} ${self.extra_javascript()} ${self.core_styles()} ${self.extra_styles()} + + ## 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()"> - ${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')))} - ${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')}'; + + ## 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'))} +</%def> + +<%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'))} - ${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/grids.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css'))} + + ${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="header_logo()"></%def> +<%def name="render_vue_template_whole_page()"> + <script type="text/x-template" id="whole-page-template"> + <div> + <header> -<%def name="footer()"> - powered by ${h.link_to("Rattail", url('about'))} + <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)"> @@ -183,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 new file mode 100644 index 00000000..b6376448 --- /dev/null +++ b/tailbone/templates/base_meta.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/base_meta.mako" /> + +<%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'))}" /> +</%def> + +<%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> diff --git a/tailbone/templates/batch/edit.mako b/tailbone/templates/batch/edit.mako index c47c43b6..4d9e990e 100644 --- a/tailbone/templates/batch/edit.mako +++ b/tailbone/templates/batch/edit.mako @@ -1,77 +1,3 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/edit.mako" /> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js'))} - <script type="text/javascript"> - - var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'}; - - $(function() { - - $('#save-refresh').click(function() { - var form = $(this).parents('form'); - form.append($('<input type="hidden" name="refresh" value="true" />')); - form.submit(); - }); - - }); - </script> -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - - .grid-wrapper { - margin-top: 10px; - } - - </style> -</%def> - -<%def name="buttons()"> - <div class="buttons"> - % if master.refreshable: - ${h.submit('save-refresh', "Save & Refresh Data")} - % endif - % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): - <button type="button" id="execute-batch"${'' if execute_enabled else ' disabled="disabled"'}>${execute_title}</button> - % endif - </div> -</%def> - -<%def name="grid_tools()"> - % if not batch.executed: - <p>${h.link_to("Delete all rows matching current search", url('{}.delete_rows'.format(route_prefix), uuid=batch.uuid))}</p> - % endif -</%def> - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<div class="form-wrapper"> -## TODO: clean this up or fix etc..? -## % if master.edit_with_rows: -## ${form.render(buttons=capture(buttons))|n} -## % else: - ${form.render()|n} -## % endif -</div> - -% if master.edit_with_rows: - ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.grid_tools))|n} -% endif - -<div id="execution-options-dialog" style="display: none;"> - - ${h.form(url('{}.execute'.format(route_prefix), uuid=batch.uuid), name='batch-execution')} - % if master.has_execution_options(batch): - ${rendered_execution_options|n} - % endif - ${h.end_form()} - -</div> +${parent.body()} diff --git a/tailbone/templates/batch/handheld/exec_options.mako b/tailbone/templates/batch/handheld/exec_options.mako deleted file mode 100644 index af5eaf14..00000000 --- a/tailbone/templates/batch/handheld/exec_options.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- - -${form.field_div('action', form.select('action', options=ACTION_OPTIONS), label="Action to Perform")} diff --git a/tailbone/templates/batch/handheld/index.mako b/tailbone/templates/batch/handheld/index.mako deleted file mode 100644 index 72096c98..00000000 --- a/tailbone/templates/batch/handheld/index.mako +++ /dev/null @@ -1,68 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/batch/index.mako" /> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - - var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'}; - - $(function() { - - $('#execute-results-button').click(function() { - var form = $('form[name="execute-results"]'); - if (has_execution_options) { - $('#execution-options-dialog').dialog({ - title: "Execution Options", - width: 500, - 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'); - } - } - ] - }); - } else { - $(this).button('option', 'label', "Executing, please wait...").button('disable'); - form.submit(); - } - }); - - }); - - </script> -</%def> - -<%def name="grid_tools()"> - % if request.has_perm('batch.handheld.execute_results'): - <button type="button" id="execute-results-button">Execute Results</button> - % endif -</%def> - -${parent.body()} - -<div id="execution-options-dialog" style="display: none;"> - ${h.form(url('{}.execute_results'.format(route_prefix)), name='execute-results')} - ${h.csrf_token(request)} - <br /> - <p> - Please be advised, you are about to execute multiple batches! They will - be aggregated and treated as a single data source, during execution. - </p> - <br /> - % if master.has_execution_options(batch): - ${rendered_execution_options|n} - % endif - ${h.end_form()} -</div> diff --git a/tailbone/templates/batch/importer/view_row.mako b/tailbone/templates/batch/importer/view_row.mako new file mode 100644 index 00000000..7d6f121f --- /dev/null +++ b/tailbone/templates/batch/importer/view_row.mako @@ -0,0 +1,80 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view_row.mako" /> + +<%def name="context_menu_items()"> + % if not batch.executed and request.has_perm('{}.delete_row'.format(permission_prefix)): + <li>${h.link_to("Delete this Row", url('{}.delete_row'.format(route_prefix), uuid=batch.uuid, row_uuid=instance.uuid))}</li> + % endif +</%def> + +<%def name="field_diff_table()"> +% if instance.status_code == enum.IMPORTER_BATCH_ROW_STATUS_CREATE: + <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 diff_fields: + <tr> + <td class="field">${field}</td> + <td class="value old-value"> </td> + <td class="value new-value">${repr(diff_new_values[field])}</td> + </tr> + % endfor + </tbody> + </table> +% elif instance.status_code in (enum.IMPORTER_BATCH_ROW_STATUS_UPDATE, enum.IMPORTER_BATCH_ROW_STATUS_NOCHANGE): + <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 diff_fields: + <tr${' class="diff"' if diff_new_values[field] != diff_old_values[field] else ''|n}> + <td class="field">${field}</td> + <td class="value old-value">${repr(diff_old_values[field])}</td> + <td class="value new-value">${repr(diff_new_values[field])}</td> + </tr> + % endfor + </tbody> + </table> +% elif instance.status_code == enum.IMPORTER_BATCH_ROW_STATUS_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 diff_fields: + <tr> + <td class="field">${field}</td> + <td class="value old-value">${repr(diff_old_values[field])}</td> + <td class="value new-value"> </td> + </tr> + % endfor + </tbody> + </table> +% endif +</%def> + +<%def name="render_form()"> + <div class="form"> + <tailbone-form></tailbone-form> + <br /> + ${self.field_diff_table()} + </div> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 9fe16c87..bea10a97 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -1,4 +1,133 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -${parent.body()} +<%def name="grid_tools()"> + ${parent.grid_tools()} + + ## Refresh Results + % if master.results_refreshable and master.has_perm('refresh'): + <b-button type="is-primary" + :disabled="refreshResultsButtonDisabled" + icon-pack="fas" + icon-left="redo" + @click="refreshResults()"> + {{ refreshResultsButtonText }} + </b-button> + ${h.form(url('{}.refresh_results'.format(route_prefix)), ref='refreshResultsForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif + + ## Execute Results + % if master.results_executable and master.has_perm('execute_multiple'): + <b-button type="is-primary" + @click="executeResults()" + icon-pack="fas" + icon-left="arrow-circle-right" + :disabled="!total"> + Execute Results + </b-button> + + <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.render_vue_tag(ref='executeResultsForm')} + </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> + + </div> + </b-modal> + % endif +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.results_executable and master.has_perm('execute_multiple'): + ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if master.results_refreshable and master.has_perm('refresh'): + <script> + + TailboneGridData.refreshResultsButtonText = "Refresh Results" + TailboneGridData.refreshResultsButtonDisabled = false + + TailboneGrid.methods.refreshResults = function() { + this.refreshResultsButtonDisabled = true + this.refreshResultsButtonText = "Working, please wait..." + this.$refs.refreshResultsForm.submit() + } + + </script> + % endif + % if master.results_executable and master.has_perm('execute_multiple'): + <script> + + ${execute_form.vue_component}.methods.submit = function() { + this.$refs.actualExecuteForm.submit() + } + + TailboneGridData.hasExecutionOptions = ${json.dumps(master.has_execution_options(batch))|n} + TailboneGridData.showExecutionOptions = false + + TailboneGrid.methods.executeResults = function() { + + // this should never happen since we disable the button when there are no results + if (!this.total) { + alert("There are no batch results to execute.") + return + } + + if (this.hasExecutionOptions) { + // show execution options modal, user can submit form from there + this.showExecutionOptions = true + + } else { + // no execution options, but this still warrants a basic confirmation + if (confirm("Are you sure you wish to execute all " + this.total.toLocaleString('en') + " batches?")) { + alert('TODO: ok then you asked for it') + } + } + } + + TailboneGrid.methods.submitExecuteResults = function() { + this.$refs.executeResultsForm.submit() + } + + </script> + % endif +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + % if master.results_executable and master.has_perm('execute_multiple'): + ${execute_form.render_vue_finalize()} + % endif +</%def> diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako new file mode 100644 index 00000000..cddaa2c5 --- /dev/null +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -0,0 +1,305 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + +<%def name="title()">Inventory Form</%def> + +<%def name="object_helpers()"> + <nav class="panel"> + <p class="panel-heading">Batch</p> + <div class="panel-block buttons"> + <div style="display: flex; flex-direction: column;"> + + <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"> + + let ${form.vue_component} = { + template: '#${form.component}-template', + mixins: [SimpleRequestMixin], + + mounted() { + this.$refs.productUPC.focus() + }, + + methods: { + + clearProduct() { + this.productInfo = {} + ## this.productNotFound = false + this.alreadyPresentInBatch = false + this.forceUnitItem = false + this.productCases = null + this.productUnits = null + }, + + assertQuantity() { + + % if allow_cases: + let cases = parseFloat(this.productCases) + if (!isNaN(cases)) { + if (cases > 999999) { + alert("Case amount is invalid!") + this.$refs.productCases.focus() + return false + } + return true + } + % endif + + let units = parseFloat(this.productUnits) + if (!isNaN(units)) { + if (units > 999999) { + alert("Unit amount is invalid!") + this.$refs.productUnits.focus() + return false + } + return true + } + + 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 + } + + this.$nextTick(() => { + if (this.productInfo.type2) { + this.$refs.productUnits.focus() + } else { + % if allow_cases and prefer_cases: + if (this.productCases) { + this.$refs.productCases.focus() + } else if (this.productUnits) { + this.$refs.productUnits.focus() + } else { + this.$refs.productCases.focus() + } + % else: + this.$refs.productUnits.focus() + % endif + } + }) + + } else { + ## this.productNotFound = true + alert("Product not found!") + + // focus/select UPC entry + this.$refs.productUPC.focus() + // nb. must traverse into the <b-input> element + this.$refs.productUPC.$el.firstChild.select() + } + + }, 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: {}, + + % if allow_cases: + productCases: null, + % endif + productUnits: null, + + alreadyPresentInBatch: false, + forceUnitItem: false, + } + + </script> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ThisPageData.toggleCompleteSubmitting = false + </script> +</%def> 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 new file mode 100644 index 00000000..d9d62bd1 --- /dev/null +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -0,0 +1,39 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/create.mako" /> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n} + + ${form.vue_component}Data.vendorName = null + ${form.vue_component}Data.vendorNameReplacement = null + + ${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> diff --git a/tailbone/templates/batch/vendorcatalog/index.mako b/tailbone/templates/batch/vendorcatalog/index.mako new file mode 100644 index 00000000..fa6e4a5a --- /dev/null +++ b/tailbone/templates/batch/vendorcatalog/index.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if h.route_exists(request, 'vendors') and request.has_perm('vendors.list'): + <li>${h.link_to("View Vendors", url('vendors'))}</li> + % endif +</%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 13ba7ecb..7c81ab0e 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -1,95 +1,351 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.batch.js'))} - <script type="text/javascript"> - - var has_execution_options = ${'true' if master.has_execution_options(batch) else 'false'}; - - $(function() { - % if master.has_worksheet: - $('.load-worksheet').click(function() { - $(this).button('disable').button('option', 'label', "Working, please wait..."); - location.href = '${url('{}.worksheet'.format(route_prefix), uuid=batch.uuid)}'; - }); - % endif - $('#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)}'; - }); - }); - - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - .grid-wrapper { - margin-top: 10px; + .modal-card-body label { + white-space: nowrap; } - - </style> -</%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if master.cloneable and request.has_perm('{}.clone'.format(permission_prefix)): - <li>${h.link_to("Clone as new batch", url('{}.clone'.format(route_prefix), uuid=batch.uuid))}</li> - % endif + .markdown p { + margin-bottom: 1.5rem; + } + + </style> </%def> <%def name="buttons()"> <div class="buttons"> ${self.leading_buttons()} ${refresh_button()} - ${execute_button()} + ${self.trailing_buttons()} </div> </%def> <%def name="leading_buttons()"> - % if master.has_worksheet and master.allow_worksheet(batch) and request.has_perm('{}.worksheet'.format(permission_prefix)): - <button type="button" class="load-worksheet">Edit as Worksheet</button> + % if master.has_worksheet and master.allow_worksheet(batch) and master.has_perm('worksheet'): + <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.viewing and master.batch_refreshable(batch) and request.has_perm('{}.refresh'.format(permission_prefix)): - <button type="button" id="refresh-data">Refresh data</button> + % if master.batch_refreshable(batch) and master.has_perm('refresh'): + ## 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="execute_button()"> - % if not batch.executed and request.has_perm('{}.execute'.format(permission_prefix)): - <button type="button" id="execute-batch"${'' if execute_enabled else ' disabled="disabled"'}>${execute_title}</button> +<%def name="trailing_buttons()"> + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + <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> -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<div class="form-wrapper"> - ${form.render(form_id='batch-form', buttons=capture(buttons))|n} -</div><!-- form-wrapper --> - -${rows_grid|n} - -% if not batch.executed: - <div id="execution-options-dialog" style="display: none;"> - - ${h.form(url('{}.execute'.format(route_prefix), uuid=batch.uuid), name='batch-execution')} - ${h.csrf_token(request)} - % if master.has_execution_options(batch): - ${rendered_execution_options|n} - % endif - ${h.end_form()} +<%def name="object_helpers()"> + ${self.render_status_breakdown()} + ${self.render_execute_helper()} +</%def> +<%def name="render_status_breakdown()"> + <nav class="panel"> + <p class="panel-heading">Row Status</p> + <div class="panel-block"> + <div style="width: 100%;"> + ${status_breakdown_grid} + </div> </div> -% endif + </nav> +</%def> + +<%def name="render_execute_helper()"> + <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> + ${h.pretty_datetime(request.rattail_config, batch.executed)} + by ${batch.executed_by} + </p> + % elif master.handler.executable(batch): + % if master.has_perm('execute'): + <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: + <b-modal has-modal-card + :active.sync="showExecutionDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Execute ${model_title}</p> + </header> + + <section class="modal-card-body"> + <p class="block has-text-weight-bold"> + What will happen when this batch is executed? + </p> + <div class="markdown"> + ${execution_described|n} + </div> + ${execute_form.render_vue_tag(ref='executeBatchForm')} + </section> + + <footer class="modal-card-foot"> + <b-button @click="showExecutionDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="submitExecuteBatch()" + icon-left="arrow-circle-right" + text="Execute Batch"> + </once-button> + </footer> + + </div> + </b-modal> + % 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> + </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'): + <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> + + <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.render_vue_tag(ref='uploadForm')} + </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="upload" + :disabled="uploadButtonDisabled"> + {{ uploadButtonText }} + </b-button> + </footer> + + </div> + </b-modal> + % endif + +</%def> + +<%def name="render_form()"> + <div class="form"> + <${form.component} @show-upload="showUploadDialog = true"> + </${form.component}> + </div> +</%def> + +<%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"> + + <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'): + + ThisPageData.showUploadDialog = false + ThisPageData.uploadButtonText = "Upload & Update Batch" + ThisPageData.uploadButtonDisabled = false + + ThisPage.methods.submitUpload = function() { + let form = this.$refs.uploadForm + let value = form.field_model_worksheet_file + if (!value) { + alert("Please choose a file to upload.") + return + } + this.uploadButtonDisabled = true + this.uploadButtonText = "Working, please wait..." + form.submit() + } + + ${upload_worksheet_form.vue_component}.methods.submit = function() { + this.$refs.actualUploadForm.submit() + } + + ## end 'external_worksheet' + % endif + + % if execute_enabled and master.has_perm('execute'): + + ThisPageData.showExecutionDialog = false + + ThisPage.methods.submitExecuteBatch = function() { + this.$refs.executeBatchForm.submit() + } + + ${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_vue_components()"> + ${parent.make_vue_components()} + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + ${upload_worksheet_form.render_vue_finalize()} + % endif + % if execute_enabled and master.has_perm('execute'): + ${execute_form.render_vue_finalize()} + % endif +</%def> diff --git a/tailbone/templates/batch/worksheet.mako b/tailbone/templates/batch/worksheet.mako index 4f91ea3d..a0dca748 100644 --- a/tailbone/templates/batch/worksheet.mako +++ b/tailbone/templates/batch/worksheet.mako @@ -1,23 +1,5 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <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> -</%def> +<%inherit file="/page.mako" /> <%def name="extra_styles()"> ${parent.extra_styles()} @@ -41,5 +23,9 @@ <%def name="worksheet_grid()"></%def> +<%def name="page_content()"> + ${self.worksheet_grid()} +</%def> -${self.worksheet_grid()} + +${parent.body()} diff --git a/tailbone/templates/change_password.mako b/tailbone/templates/change_password.mako index d9c67235..c64acebf 100644 --- a/tailbone/templates/change_password.mako +++ b/tailbone/templates/change_password.mako @@ -1,17 +1,7 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> <%def name="title()">Change Password</%def> -<div class="form"> - ${h.form(url('change_password'))} - ${form.csrf_token()} - ${form.referrer_field()} - ${form.field_div('current_password', form.password('current_password'))} - ${form.field_div('new_password', form.password('new_password'))} - ${form.field_div('confirm_password', form.password('confirm_password'))} - <div class="buttons"> - ${h.submit('submit', "Change Password")} - </div> - ${h.end_form()} -</div> + +${parent.body()} 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/crud.mako b/tailbone/templates/crud.mako deleted file mode 100644 index b1afa0e5..00000000 --- a/tailbone/templates/crud.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/form.mako" /> - -<%def name="title()">${"New "+form.pretty_name if form.creating else form.pretty_name+' : '+capture(self.model_title)}</%def> - -<%def name="model_title()">${h.literal(unicode(form.fieldset.model))}</%def> - -<%def name="head_tags()"> - ${parent.head_tags()} - <script type="text/javascript"> - $(function() { - $('a.delete').click(function() { - if (! confirm("Do you really wish to delete this object?")) { - return false; - } - }); - }); - </script> -</%def> - -${parent.body()} 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 new file mode 100644 index 00000000..490e4757 --- /dev/null +++ b/tailbone/templates/customers/view.mako @@ -0,0 +1,38 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> +<%namespace file="/util.mako" import="view_profiles_helper" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if show_profiles_helper and show_profiles_people: + ${view_profiles_helper(show_profiles_people)} + % endif +</%def> + +<%def name="render_form()"> + <div class="form"> + <tailbone-form @detach-person="detachPerson"> + </tailbone-form> + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % 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 for now we just redirect.. + if (confirm("Are you sure you want to detach this person from this customer account?")) { + location.href = url + } + } + + </script> +</%def> 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 new file mode 100644 index 00000000..382a121f --- /dev/null +++ b/tailbone/templates/custorders/create.mako @@ -0,0 +1,2406 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> +<%namespace name="product_lookup" file="/products/lookup.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .this-page-content { + flex-grow: 1; + } + </style> +</%def> + +<%def name="page_content()"> + <br /> + <customer-order-creator></customer-order-creator> +</%def> + +<%def name="order_form_buttons()"> + <div class="level"> + <div class="level-left"> + </div> + <div class="level-right"> + <div class="level-item"> + <div class="buttons"> + <b-button type="is-primary" + @click="submitOrder()" + :disabled="submittingOrder" + icon-pack="fas" + icon-left="upload"> + {{ submittingOrder ? "Working, please wait..." : "Submit this Order" }} + </b-button> + <b-button @click="startOverEntirely()" + icon-pack="fas" + icon-left="redo"> + Start Over Entirely + </b-button> + <b-button @click="cancelOrder()" + type="is-danger" + icon-pack="fas" + icon-left="trash"> + Cancel this Order + </b-button> + </div> + </div> + </div> + </div> +</%def> + +<%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" + % if request.use_oruga: + v-model:open="customerPanelOpen" + % else: + :open.sync="customerPanelOpen" + % 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 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;"> + % 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()" --> + <!-- icon-pack="fas" --> + <!-- icon-left="fas fa-redo"> --> + <!-- Start Over --> + <!-- </b-button> --> + <!-- </div> --> + </div> + + <br /> + <div class="field"> + <b-radio v-model="contactIsKnown" + :native-value="true"> + Customer is already in the system. + </b-radio> + </div> + + <div v-show="contactIsKnown" + style="padding-left: 10rem; display: flex;"> + + <div :style="{'flex-grow': contactNotes.length ? 0 : 1}"> + + <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" + % if new_order_requires_customer: + serviceUrl="${url('{}.customer_autocomplete'.format(route_prefix))}" + % else: + serviceUrl="${url('{}.person_autocomplete'.format(route_prefix))}" + % endif + % if request.use_oruga: + :assigned-label="contactDisplay" + @update:model-value="contactChanged" + % else: + :initial-label="contactDisplay" + @input="contactChanged" + % endif + > + </tailbone-autocomplete> + <b-button v-if="contactUUID && contactProfileURL" + type="is-primary" + tag="a" target="_blank" + :href="contactProfileURL" + icon-pack="fas" + icon-left="external-link-alt"> + View Profile + </b-button> + <b-button v-if="contactUUID" + @click="refreshContact" + icon-pack="fas" + icon-left="redo" + :disabled="refreshingContact"> + {{ refreshingContact ? "Refreshig" : "Refresh" }} + </b-button> + </div> + </b-field> + + <b-field grouped v-show="contactUUID" + style="margin-top: 2rem;"> + + <b-field label="Phone Number" + style="margin-right: 3rem;"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <div v-if="orderPhoneNumber"> + <p :class="addOtherPhoneNumber ? 'has-text-success': null"> + {{ orderPhoneNumber }} + </p> + <p v-if="addOtherPhoneNumber" + class="is-size-7 is-italic has-text-success"> + will be added to customer record + </p> + </div> + <p v-if="!orderPhoneNumber" + class="has-text-danger"> + (no valid phone number on file) + </p> + </div> + % if allow_contact_info_choice: + <div class="level-item" + % if not allow_contact_info_create: + v-if="contactPhones.length > 1" + % endif + > + <b-button type="is-primary" + @click="editPhoneNumberInit()" + icon-pack="fas" + icon-left="edit"> + Edit + </b-button> + + <${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"> + <p class="modal-card-title">Edit Phone Number</p> + </header> + + <section class="modal-card-body"> + + <b-field v-for="phone in contactPhones" + :key="phone.uuid"> + <b-radio v-model="existingPhoneUUID" + :native-value="phone.uuid"> + {{ phone.type }} {{ phone.number }} + <span v-if="phone.preferred" + class="is-italic"> + (preferred) + </span> + </b-radio> + </b-field> + + % if allow_contact_info_create: + <b-field> + <b-radio v-model="existingPhoneUUID" + :native-value="null"> + other + </b-radio> + </b-field> + + <b-field v-if="!existingPhoneUUID" + grouped> + <b-input v-model="editPhoneNumberOther"> + </b-input> + <b-checkbox v-model="editPhoneNumberAddOther"> + add this phone number to customer record + </b-checkbox> + </b-field> + % endif + + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editPhoneNumberSaveDisabled" + @click="editPhoneNumberSave()"> + {{ editPhoneNumberSaveText }} + </b-button> + <b-button @click="editPhoneNumberShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + </div> + % endif + </div> + </div> + </b-field> + + <b-field label="Email Address"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <div v-if="orderEmailAddress"> + <p :class="addOtherEmailAddress ? 'has-text-success' : null"> + {{ orderEmailAddress }} + </p> + <p v-if="addOtherEmailAddress" + class="is-size-7 is-italic has-text-success"> + will be added to customer record + </p> + </div> + <span v-if="!orderEmailAddress" + class="has-text-danger"> + (no valid email address on file) + </span> + </div> + % if allow_contact_info_choice: + <div class="level-item" + % if not allow_contact_info_create: + v-if="contactEmails.length > 1" + % endif + > + <b-button type="is-primary" + @click="editEmailAddressInit()" + icon-pack="fas" + icon-left="edit"> + Edit + </b-button> + <${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"> + <p class="modal-card-title">Edit Email Address</p> + </header> + + <section class="modal-card-body"> + + <b-field v-for="email in contactEmails" + :key="email.uuid"> + <b-radio v-model="existingEmailUUID" + :native-value="email.uuid"> + {{ email.type }} {{ email.address }} + <span v-if="email.preferred" + class="is-italic"> + (preferred) + </span> + </b-radio> + </b-field> + + % if allow_contact_info_create: + <b-field> + <b-radio v-model="existingEmailUUID" + :native-value="null"> + other + </b-radio> + </b-field> + + <b-field v-if="!existingEmailUUID" + grouped> + <b-input v-model="editEmailAddressOther"> + </b-input> + <b-checkbox v-model="editEmailAddressAddOther"> + add this email address to customer record + </b-checkbox> + </b-field> + % endif + + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editEmailAddressSaveDisabled" + @click="editEmailAddressSave()"> + {{ editEmailAddressSaveText }} + </b-button> + <b-button @click="editEmailAddressShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + </div> + % endif + </div> + </div> + </b-field> + + </b-field> + </div> + + <div v-show="contactNotes.length" + style="margin-left: 1rem;"> + <b-notification v-for="note in contactNotes" + :key="note" + type="is-warning" + :closable="false"> + {{ note }} + </b-notification> + </div> + </div> + + <br /> + <div class="field"> + <b-radio v-model="contactIsKnown" + :native-value="false"> + Customer is not yet in the system. + </b-radio> + </div> + + <div v-if="!contactIsKnown" + style="padding-left: 10rem; display: flex;"> + <div> + <b-field grouped> + <b-field label="First Name"> + <span class="has-text-success"> + {{ newCustomerFirstName }} + </span> + </b-field> + <b-field label="Last Name"> + <span class="has-text-success"> + {{ newCustomerLastName }} + </span> + </b-field> + </b-field> + <b-field grouped> + <b-field label="Phone Number"> + <span class="has-text-success"> + {{ newCustomerPhone }} + </span> + </b-field> + <b-field label="Email Address"> + <span class="has-text-success"> + {{ newCustomerEmail }} + </span> + </b-field> + </b-field> + </div> + + <div> + <b-button type="is-primary" + @click="editNewCustomerInit()" + icon-pack="fas" + icon-left="edit"> + Edit New Customer + </b-button> + </div> + + <div style="margin-left: 1rem;"> + <b-notification type="is-warning" + :closable="false"> + <p>Duplicate records can be difficult to clean up!</p> + <p>Please be sure the customer is not already in the system.</p> + </b-notification> + </div> + + <${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"> + <p class="modal-card-title">Edit New Customer</p> + </header> + + <section class="modal-card-body"> + <b-field grouped> + <b-field label="First Name"> + <b-input v-model.trim="editNewCustomerFirstName" + ref="editNewCustomerInput"> + </b-input> + </b-field> + <b-field label="Last Name"> + <b-input v-model.trim="editNewCustomerLastName"> + </b-input> + </b-field> + </b-field> + <b-field grouped> + <b-field label="Phone Number"> + <b-input v-model.trim="editNewCustomerPhone"></b-input> + </b-field> + <b-field label="Email Address"> + <b-input v-model.trim="editNewCustomerEmail"></b-input> + </b-field> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + :disabled="editNewCustomerSaveDisabled" + @click="editNewCustomerSave()"> + {{ editNewCustomerSaveText }} + </b-button> + <b-button @click="editNewCustomerShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + </div> + + </div> + </div> <!-- panel-block --> + </${b}-collapse> + + <${b}-collapse class="panel" + open> + + <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="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 + % if request.use_oruga: + v-model:active="showingItemDialog" + % else: + :active.sync="showingItemDialog" + % endif + > + <div class="card"> + <div class="card-content"> + + <${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" + value="product"> + + <div class="field"> + <b-radio v-model="productIsKnown" + :native-value="true"> + Product is already in the system. + </b-radio> + </div> + + <div v-show="productIsKnown" + style="padding-left: 3rem; display: flex; gap: 1rem;"> + + <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> + + <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" + % if not allow_unknown_product: + disabled + % endif + :native-value="false"> + Product is not yet in the system. + </b-radio> + </div> + + <div v-show="!productIsKnown" + style="padding-left: 5rem;"> + + <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> + + <b-field grouped> + + <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="save"> + {{ itemDialogSaving ? "Working, please wait..." : (this.editingItem ? "Update Item" : "Add Item") }} + </b-button> + </div> + + </div> + </div> + </${b}-modal> + + % if unknown_product_confirm_price: + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="confirmPriceShowDialog" + % else: + :active.sync="confirmPriceShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Confirm Price</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Please confirm the price info before proceeding. + </p> + + <div style="white-space: nowrap;"> + + <b-field label="Unit Cost" horizontal> + <span>{{ pendingProduct.unit_cost }}</span> + </b-field> + + <b-field label="Unit Reg. Price" horizontal> + <span>{{ pendingProduct.regular_price_amount }}</span> + </b-field> + + <b-field label="Gross Margin" horizontal> + <span>{{ pendingProductGrossMargin }}</span> + </b-field> + + </div> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="check" + @click="confirmPriceSave()"> + Confirm + </b-button> + <b-button @click="confirmPriceCancel()"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + % endif + + % if allow_past_item_reorder: + <${b}-modal + % 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 }} + </span> + </${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" + 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="#" + % 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> + + </${b}-table> + </div> + </div> + </${b}-collapse> + + ${self.order_form_buttons()} + + ${h.form(request.current_route_url(), ref='batchActionForm')} + ${h.csrf_token(request)} + ${h.hidden('action', **{'v-model': 'batchAction'})} + ${h.end_form()} + + </div> + </script> + <script> + + const CustomerOrderCreator = { + template: '#customer-order-creator-template', + mixins: [SimpleRequestMixin], + data() { + + let defaultUnitChoices = ${json.dumps(default_uom_choices)|n} + let defaultUOM = ${json.dumps(default_uom)|n} + + return { + batchAction: null, + batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n}, + + customerPanelOpen: false, + contactIsKnown: ${json.dumps(contact_is_known)|n}, + % if new_order_requires_customer: + contactUUID: ${json.dumps(batch.customer_uuid)|n}, + % else: + contactUUID: ${json.dumps(batch.person_uuid)|n}, + % endif + 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}, + addOtherPhoneNumber: ${json.dumps(add_phone_number)|n}, + + orderEmailAddress: ${json.dumps(batch.email_address)|n}, + contactEmails: ${json.dumps(contact_emails)|n}, + addOtherEmailAddress: ${json.dumps(add_email_address)|n}, + + % if allow_contact_info_choice: + + editPhoneNumberShowDialog: false, + editPhoneNumberOther: null, + editPhoneNumberAddOther: false, + existingPhoneUUID: null, + editPhoneNumberSaving: false, + + editEmailAddressShowDialog: false, + editEmailAddressOther: null, + editEmailAddressAddOther: false, + existingEmailUUID: null, + editEmailAddressOther: null, + editEmailAddressSaving: false, + + % endif + + newCustomerFirstName: ${json.dumps(new_customer_first_name)|n}, + newCustomerLastName: ${json.dumps(new_customer_last_name)|n}, + newCustomerPhone: ${json.dumps(new_customer_phone)|n}, + newCustomerEmail: ${json.dumps(new_customer_email)|n}, + contactNotes: ${json.dumps(contact_notes)|n}, + + editNewCustomerShowDialog: false, + editNewCustomerFirstName: null, + editNewCustomerLastName: null, + editNewCustomerPhone: null, + editNewCustomerEmail: null, + editNewCustomerSaving: false, + + 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, + 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, + defaultUOM: defaultUOM, + productUOM: defaultUOM, + productCaseSize: null, + + % 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, + } + }, + computed: { + customerPanelHeader() { + let text = "Customer" + + if (this.contactIsKnown) { + if (this.contactUUID) { + if (this.$refs.contactAutocomplete) { + text = "Customer: " + this.$refs.contactAutocomplete.getDisplayText() + } else { + text = "Customer: " + this.contactDisplay + } + } + } else { + if (this.contactDisplay) { + text = "Customer: " + this.contactDisplay + } + } + + if (!this.customerPanelOpen) { + text += ' <p class="' + this.customerHeaderClass + '" style="display: inline-block; float: right;">' + this.customerStatusText + '</p>' + } + + return text + }, + customerHeaderClass() { + if (!this.customerPanelOpen) { + if (this.customerStatusType == 'is-danger') { + return 'has-text-white' + } + } + }, + customerPanelType() { + if (!this.customerPanelOpen) { + return this.customerStatusType + } + }, + customerStatusType() { + return this.customerStatusTypeAndText.type + }, + customerStatusText() { + return this.customerStatusTypeAndText.text + }, + customerStatusTypeAndText() { + let phoneNumber = null + if (this.contactIsKnown) { + if (!this.contactUUID) { + return { + type: 'is-danger', + text: "Please identify the customer.", + } + } + if (!this.orderPhoneNumber) { + return { + type: 'is-warning', + text: "Please provide a phone number for the customer.", + } + } + if (this.contactNotes.length) { + return { + type: 'is-warning', + text: "Please review notes below.", + } + } + phoneNumber = this.orderPhoneNumber + } else { // customer is not known + if (!this.contactDisplay) { + return { + type: 'is-danger', + text: "Please identify the customer.", + } + } + if (!this.newCustomerPhone) { + return { + type: 'is-warning', + text: "Please provide a phone number for the customer.", + } + } + phoneNumber = this.newCustomerPhone + } + + let phoneDigits = phoneNumber.replace(/\D/g, '') + if (!phoneDigits.length || (phoneDigits.length != 7 && phoneDigits.length != 10)) { + return { + type: 'is-warning', + text: "The phone number does not appear to be valid.", + } + } + + if (!this.contactIsKnown) { + return { + type: 'is-warning', + text: "Will create a new customer record.", + } + } + + return { + type: null, + text: "Customer info looks okay.", + } + }, + + % if allow_contact_info_choice: + + editPhoneNumberSaveDisabled() { + if (this.editPhoneNumberSaving) { + return true + } + if (!this.existingPhoneUUID && !this.editPhoneNumberOther) { + return true + } + return false + }, + + editPhoneNumberSaveText() { + if (this.editPhoneNumberSaving) { + return "Working, please wait..." + } + return "Save" + }, + + editEmailAddressSaveDisabled() { + if (this.editEmailAddressSaving) { + return true + } + if (!this.existingEmailUUID && !this.editEmailAddressOther) { + return true + } + return false + }, + + editEmailAddressSaveText() { + if (this.editEmailAddressSaving) { + return "Working, please wait..." + } + return "Save" + }, + + % endif + + editNewCustomerSaveDisabled() { + if (this.editNewCustomerSaving) { + return true + } + if (!(this.editNewCustomerFirstName && this.editNewCustomerLastName)) { + return true + } + if (!(this.editNewCustomerPhone || this.editNewCustomerEmail)) { + return true + } + return false + }, + + editNewCustomerSaveText() { + if (this.editNewCustomerSaving) { + return "Working, please wait..." + } + return "Save" + }, + + itemsPanelHeader() { + let text = "Items" + + if (this.items.length) { + text = "Items: " + this.items.length.toString() + " for " + this.batchTotalPriceDisplay + } + + return text + }, + + % if allow_item_discounts: + + allowItemDiscount() { + if (!this.allowDiscountsIfOnSale) { + if (this.productSalePriceDisplay) { + return false + } + } + return true + }, + + % endif + + pendingProductGrossMargin() { + let cost = this.pendingProduct.unit_cost + let price = this.pendingProduct.regular_price_amount + if (cost && price) { + let margin = (price - cost) / price + return (100 * margin).toFixed(2).toString() + " %" + } + }, + + itemDialogSaveDisabled() { + + if (this.itemDialogSaving) { + return true + } + + if (this.productIsKnown) { + if (!this.productUUID) { + return true + } + + } else { + for (let field of this.pendingProductRequiredFields) { + if (!this.pendingProduct[field]) { + return true + } + } + } + + if (!this.productUOM) { + return true + } + + return false + }, + }, + mounted() { + if (this.customerStatusType) { + this.customerPanelOpen = true + } + }, + watch: { + + contactIsKnown: function(val) { + + // 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() + } + }, + }, + methods: { + + startOverEntirely() { + let msg = "Are you sure you want to start over entirely?\n\n" + + "This will totally delete this order and start a new one." + if (!confirm(msg)) { + return + } + this.batchAction = 'start_over_entirely' + this.$nextTick(function() { + this.$refs.batchActionForm.submit() + }) + }, + + // startOverCustomer(confirmed) { + // if (!confirmed) { + // let msg = "Are you sure you want to start over for the customer data?" + // if (!confirm(msg)) { + // return + // } + // } + // this.contactIsKnown = true + // this.contactUUID = null + // // this.customerEntry = null + // this.phoneNumberEntry = null + // this.customerName = null + // this.phoneNumber = null + // }, + + // startOverItem(confirmed) { + // if (!confirmed) { + // let msg = "Are you sure you want to start over for the item data?" + // if (!confirm(msg)) { + // return + // } + // } + // // TODO: reset things + // }, + + cancelOrder() { + let msg = "Are you sure you want to cancel?\n\n" + + "This will totally delete the current order." + if (!confirm(msg)) { + return + } + this.batchAction = 'delete_batch' + this.$nextTick(function() { + this.$refs.batchActionForm.submit() + }) + }, + + submitBatchData(params, success, failure) { + let url = ${json.dumps(request.current_route_url())|n} + + this.simplePOST(url, params, response => { + if (success) { + success(response) + } + }, response => { + if (failure) { + failure(response) + } + }) + }, + + submitOrder() { + this.submittingOrder = true + + let params = { + action: 'submit_new_order', + } + + this.submitBatchData(params, response => { + if (response.data.next_url) { + location.href = response.data.next_url + } else { + location.reload() + } + }, response => { + this.submittingOrder = false + }) + }, + + 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 = { + action: 'unassign_contact', + } + } else { + params = { + action: 'assign_contact', + uuid: this.contactUUID, + } + } + this.submitBatchData(params, response => { + % if new_order_requires_customer: + this.contactUUID = response.data.customer_uuid + % else: + this.contactUUID = response.data.person_uuid + % endif + 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.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: + + editPhoneNumberInit() { + this.existingPhoneUUID = null + let normalOrderPhone = (this.orderPhoneNumber || '').replace(/\D/g, '') + for (let phone of this.contactPhones) { + let normal = phone.number.replace(/\D/g, '') + if (normal == normalOrderPhone) { + this.existingPhoneUUID = phone.uuid + break + } + } + this.editPhoneNumberOther = this.existingPhoneUUID ? null : this.orderPhoneNumber + this.editPhoneNumberAddOther = this.addOtherPhoneNumber + this.editPhoneNumberShowDialog = true + }, + + editPhoneNumberSave() { + this.editPhoneNumberSaving = true + + let params = { + action: 'update_phone_number', + phone_number: null, + } + + if (this.existingPhoneUUID) { + for (let phone of this.contactPhones) { + if (phone.uuid == this.existingPhoneUUID) { + params.phone_number = phone.number + break + } + } + } + + if (params.phone_number) { + params.add_phone_number = false + } else { + params.phone_number = this.editPhoneNumberOther + params.add_phone_number = this.editPhoneNumberAddOther + } + + this.submitBatchData(params, response => { + if (response.data.success) { + this.orderPhoneNumber = response.data.phone_number + this.addOtherPhoneNumber = response.data.add_phone_number + this.editPhoneNumberShowDialog = false + } else { + this.$buefy.toast.open({ + message: "Save failed: " + response.data.error, + type: 'is-danger', + duration: 2000, // 2 seconds + }) + } + this.editPhoneNumberSaving = false + }) + + }, + + editEmailAddressInit() { + this.existingEmailUUID = null + let normalOrderEmail = (this.orderEmailAddress || '').toLowerCase() + for (let email of this.contactEmails) { + let normal = email.address.toLowerCase() + if (normal == normalOrderEmail) { + this.existingEmailUUID = email.uuid + break + } + } + this.editEmailAddressOther = this.existingEmailUUID ? null : this.orderEmailAddress + this.editEmailAddressAddOther = this.addOtherEmailAddress + this.editEmailAddressShowDialog = true + }, + + editEmailAddressSave() { + this.editEmailAddressSaving = true + + let params = { + action: 'update_email_address', + email_address: null, + } + + if (this.existingEmailUUID) { + for (let email of this.contactEmails) { + if (email.uuid == this.existingEmailUUID) { + params.email_address = email.address + break + } + } + } + + if (params.email_address) { + params.add_email_address = false + } else { + params.email_address = this.editEmailAddressOther + params.add_email_address = this.editEmailAddressAddOther + } + + this.submitBatchData(params, response => { + if (response.data.success) { + this.orderEmailAddress = response.data.email_address + this.addOtherEmailAddress = response.data.add_email_address + this.editEmailAddressShowDialog = false + } else { + this.$buefy.toast.open({ + message: "Save failed: " + response.data.error, + type: 'is-danger', + duration: 2000, // 2 seconds + }) + } + this.editEmailAddressSaving = false + }) + }, + + % endif + + editNewCustomerInit() { + this.editNewCustomerFirstName = this.newCustomerFirstName + this.editNewCustomerLastName = this.newCustomerLastName + this.editNewCustomerPhone = this.newCustomerPhone + this.editNewCustomerEmail = this.newCustomerEmail + this.editNewCustomerShowDialog = true + this.$nextTick(() => { + this.$refs.editNewCustomerInput.focus() + }) + }, + + editNewCustomerSave() { + this.editNewCustomerSaving = true + + let params = { + action: 'update_pending_customer', + first_name: this.editNewCustomerFirstName, + last_name: this.editNewCustomerLastName, + phone_number: this.editNewCustomerPhone, + email_address: this.editNewCustomerEmail, + } + + this.submitBatchData(params, response => { + if (response.data.success) { + this.contactDisplay = response.data.new_customer_name + this.newCustomerFirstName = response.data.new_customer_first_name + this.newCustomerLastName = response.data.new_customer_last_name + this.newCustomerPhone = response.data.phone_number + this.orderPhoneNumber = response.data.phone_number + this.newCustomerEmail = response.data.email_address + this.orderEmailAddress = response.data.email_address + this.editNewCustomerShowDialog = false + } else { + this.$buefy.toast.open({ + message: "Save failed: " + (response.data.error || "(unknown error)"), + type: 'is-danger', + duration: 2000, // 2 seconds + }) + } + this.editNewCustomerSaving = false + }) + + }, + + 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.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.productLookup.focus() + }) + }, + + % 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 = !!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.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 + }, + + deleteItem(index) { + if (!confirm("Are you sure you want to delete this item?")) { + return + } + + let params = { + action: 'delete_item', + uuid: this.items[index].uuid, + } + this.submitBatchData(params, response => { + if (response.data.error) { + this.$buefy.toast.open({ + message: "Delete failed: " + response.data.error, + type: 'is-warning', + duration: 2000, // 2 seconds + }) + } else { + this.items.splice(index, 1) + this.batchTotalPriceDisplay = response.data.batch.total_price_display + } + }) + }, + + clearProduct() { + this.productUUID = null + this.productDisplay = 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 allow_item_discounts: + this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + % endif + + % if product_price_may_be_questionable: + this.productPriceNeedsConfirmation = false + % endif + }, + + setProductUnitChoices(choices) { + this.productUnitChoices = choices + + let found = false + for (let uom of choices) { + if (this.productUOM == uom.key) { + found = true + break + } + } + if (!found) { + this.productUOM = choices[0].key + } + }, + + productChanged(product) { + if (product) { + let params = { + action: 'get_product_info', + 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.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() + } + }, + + itemDialogAttemptSave() { + this.itemDialogSaving = true + + let params = { + product_is_known: this.productIsKnown, + % 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 + } else { + params.action = 'add_item' + } + + this.submitBatchData(params, response => { + + if (params.action == 'add_item') { + this.items.push(response.data.row) + + } else { // update_item + // must update each value separately, instead of + // overwriting the item record, or else display will + // not update properly + for (let [key, value] of Object.entries(response.data.row)) { + this.editingItem[key] = value + } + } + + // 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> + +<%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 new file mode 100644 index 00000000..4cc92bbf --- /dev/null +++ b/tailbone/templates/custorders/items/view.mako @@ -0,0 +1,450 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%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" + % endif + > + </${form.component}> + </div> +</%def> + +<%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"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + Current status is: + </div> + + <div class="level-item has-text-weight-bold"> + {{ orderItemStatuses[oldStatusCode] }} + </div> + + <div class="level-item" + style="margin-left: 5rem;"> + New status will be: + </div> + + <b-field class="level-item" + :type="newStatusCode ? null : 'is-danger'"> + <b-select v-model="newStatusCode"> + <option v-for="item in orderItemStatusOptions" + :key="item.key" + :value="item.key"> + {{ item.label }} + </option> + </b-select> + </b-field> + + </div> + </div> + + <div v-if="changeStatusGridData.length"> + + <p class="block"> + Please indicate any other item(s) to which the new + status should be applied: + </p> + + <b-table :data="changeStatusGridData" + checkable + :checked-rows.sync="changeStatusCheckedRows" + narrowed + class="is-size-7"> + <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 /> + </div> + + <p> + Please provide a note<span v-if="changeStatusGridData.length"> + (will be applied to all selected items)</span>: + </p> + <b-input v-model="newStatusNote" + type="textarea" rows="2"> + </b-input> + + <br /> + + <div class="buttons"> + <b-button type="is-primary" + :disabled="changeStatusSaveDisabled" + icon-pack="fas" + icon-left="save" + @click="statusChangeSave()"> + {{ changeStatusSubmitText }} + </b-button> + <b-button @click="cancelStatusChange"> + Cancel + </b-button> + </div> + + </div> + </div> + </b-modal> + ${h.form(master.get_action_url('change_status', instance), ref='changeStatusForm')} + ${h.csrf_token(request)} + ${h.hidden('new_status_code', **{'v-model': 'newStatusCode'})} + ${h.hidden('uuids', **{':value': 'changeStatusCheckedRows.map((row) => {return row.uuid}).join()'})} + ${h.hidden('note', **{':value': 'newStatusNote'})} + ${h.end_form()} + % endif + + % if master.has_perm('add_note'): + <b-modal has-modal-card + :active.sync="showAddNoteDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Add Note</p> + </header> + + <section class="modal-card-body"> + <b-field> + <b-input type="textarea" rows="8" + v-model="newNoteText" + ref="newNoteTextArea"> + </b-input> + </b-field> + <b-field> + <b-checkbox v-model="newNoteApplyAll"> + Apply to all items on this order + </b-checkbox> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="addNoteSave()" + :disabled="addNoteSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ addNoteSubmitting ? "Working, please wait..." : "Save Note" }} + </b-button> + <b-button @click="showAddNoteDialog = false"> + Cancel + </b-button> + </footer> + </div> + </b-modal> + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${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 enum.CUSTORDER_ITEM_STATUS.items()])|n} + + ThisPageData.oldStatusCode = ${instance.status_code} + + ThisPageData.showChangeStatusDialog = false + ThisPageData.newStatusCode = null + ThisPageData.changeStatusGridData = ${json.dumps(other_order_items_data)|n} + ThisPageData.changeStatusCheckedRows = [] + ThisPageData.newStatusNote = null + ThisPageData.changeStatusSubmitText = "Update Status" + ThisPageData.changeStatusSubmitting = false + + ThisPage.computed.changeStatusSaveDisabled = function() { + if (!this.newStatusCode) { + return true + } + if (this.changeStatusSubmitting) { + return true + } + return false + } + + ThisPage.methods.showChangeStatus = function() { + this.newStatusCode = null + // clear out any checked rows + this.changeStatusCheckedRows.length = 0 + this.newStatusNote = null + this.showChangeStatusDialog = true + } + + ThisPage.methods.cancelStatusChange = function() { + this.showChangeStatusDialog = false + } + + ThisPage.methods.statusChangeSave = function() { + if (this.newStatusCode == this.oldStatusCode) { + alert("You chose the same status it already had...") + return + } + + this.changeStatusSubmitting = true + this.changeStatusSubmitText = "Working, please wait..." + 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'): + + ThisPageData.showAddNoteDialog = false + ThisPageData.newNoteText = null + ThisPageData.newNoteApplyAll = false + ThisPageData.addNoteSubmitting = false + + ThisPage.computed.addNoteSaveDisabled = function() { + if (!this.newNoteText) { + return true + } + if (this.addNoteSubmitting) { + return true + } + return false + } + + ThisPage.methods.showAddNote = function() { + this.newNoteText = null + this.newNoteApplyAll = false + this.showAddNoteDialog = true + this.$nextTick(() => { + this.$refs.newNoteTextArea.focus() + }) + } + + ThisPage.methods.addNoteSave = function() { + this.addNoteSubmitting = true + + let url = '${url('{}.add_note'.format(route_prefix), uuid=instance.uuid)}' + let params = { + note: this.newNoteText, + apply_all: this.newNoteApplyAll, + } + + this.simplePOST(url, params, response => { + this.$refs.mainForm.eventsData = response.data.events + this.showAddNoteDialog = false + this.addNoteSubmitting = false + }, response => { + this.addNoteSubmitting = false + }) + } + + % endif + + </script> +</%def> 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 43fdfcc0..86f5c121 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -1,27 +1,52 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - $(function() { - - $('form[name="restart-datasync"]').submit(function() { - $(this).find('button') - .button('option', 'label', "Restarting DataSync...") - .button('disable'); - }); - - }); - </script> -</%def> - <%def name="grid_tools()"> ${parent.grid_tools()} - ${h.form(url('datasync.restart'), name='restart-datasync')} - ${h.csrf_token(request)} - <button type="submit">Restart DataSync</button> - ${h.end_form()} + + % if request.has_perm('datasync.restart'): + ${h.form(url('datasync.restart'), name='restart-datasync', class_='control', **{'@submit': 'submitRestartDatasyncForm'})} + ${h.csrf_token(request)} + <b-button native-type="submit" + :disabled="restartDatasyncFormSubmitting"> + {{ restartDatasyncFormButtonText }} + </b-button> + ${h.end_form()} + % endif + + % if allow_filemon_restart and request.has_perm('filemon.restart'): + ${h.form(url('filemon.restart'), name='restart-filemon', class_='control', **{'@submit': 'submitRestartFilemonForm'})} + ${h.csrf_token(request)} + <b-button native-type="submit" + :disabled="restartFilemonFormSubmitting"> + {{ restartFilemonFormButtonText }} + </b-button> + ${h.end_form()} + % endif + </%def> -${parent.body()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if request.has_perm('datasync.restart'): + TailboneGridData.restartDatasyncFormSubmitting = false + TailboneGridData.restartDatasyncFormButtonText = "Restart Datasync" + TailboneGrid.methods.submitRestartDatasyncForm = function() { + this.restartDatasyncFormSubmitting = true + this.restartDatasyncFormButtonText = "Restarting Datasync..." + } + % endif + + % if allow_filemon_restart and request.has_perm('filemon.restart'): + TailboneGridData.restartFilemonFormSubmitting = false + TailboneGridData.restartFilemonFormButtonText = "Restart Filemon" + TailboneGrid.methods.submitRestartFilemonForm = function() { + this.restartFilemonFormSubmitting = true + this.restartFilemonFormButtonText = "Restarting Filemon..." + } + % endif + + </script> +</%def> 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 new file mode 100644 index 00000000..7a15c7f0 --- /dev/null +++ b/tailbone/templates/deform/autocomplete_jquery.pt @@ -0,0 +1,23 @@ +<div tal:define="name name|field.name; + css_class css_class|field.widget.css_class; + oid oid|field.oid; + field_display field_display; + style style|field.widget.style; + url url|field.widget.service_url;" + tal:omit-tag=""> + + <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}" + tal:attributes=":assigned-label assigned_label or 'null'; + @input input_handler|input_callback|''; + @new-label new_label_callback|'';"> + + </tailbone-autocomplete> + </div> + +</div> diff --git a/tailbone/templates/deform/cases_units.pt b/tailbone/templates/deform/cases_units.pt new file mode 100644 index 00000000..b30d1d63 --- /dev/null +++ b/tailbone/templates/deform/cases_units.pt @@ -0,0 +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=""> + + <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> diff --git a/tailbone/templates/deform/checkbox.pt b/tailbone/templates/deform/checkbox.pt index d149f7d1..408fa1cb 100644 --- a/tailbone/templates/deform/checkbox.pt +++ b/tailbone/templates/deform/checkbox.pt @@ -1,13 +1,15 @@ -<div class="checkbox"> - <input tal:define="name name|field.name; - 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" - type="checkbox" - name="${name}" value="${true_val}" - id="${oid}" - tal:attributes="checked cstruct == true_val; - class css_class; - style style;" /> +<div tal:define="name name|field.name; + 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;" + tal:omit-tag=""> + + <div tal:define="vmodel vmodel|'field_model_' + name;"> + <b-checkbox name="${name}" + v-model="${vmodel}" + native-value="${true_val}"> + {{ ${vmodel} }} + </b-checkbox> + </div> </div> diff --git a/tailbone/templates/deform/checkbox_dynamic.pt b/tailbone/templates/deform/checkbox_dynamic.pt new file mode 100644 index 00000000..c5d4d795 --- /dev/null +++ b/tailbone/templates/deform/checkbox_dynamic.pt @@ -0,0 +1,13 @@ +<!--! -*- mode: html; -*- --> +<b-checkbox tal:define="name name|field.name; + oid oid|field.oid; + true_val true_val|field.widget.true_val; + vmodel vmodel|'field_model_' + field.name; + disabled_attr disabled_attr|'field_disabled_' + field.name;" + name="${name}" + id="${oid}" + native-value="${true_val}" + v-model="${vmodel}" + tal:attributes=":disabled disabled_attr;"> + {{ ${vmodel} }} +</b-checkbox> diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt new file mode 100644 index 00000000..2121f01d --- /dev/null +++ b/tailbone/templates/deform/checked_password.pt @@ -0,0 +1,32 @@ +<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;"> + + <div> + ${field.start_mapping()} + <b-input type="password" + name="${name}" + v-model="${vmodel}" + tal:attributes="class string: form-control ${css_class or ''}; + style style; + attributes|field.widget.attributes|{};" + id="${oid}" + i18n:attributes="placeholder" + placeholder="Password"> + </b-input> + <b-input type="password" + name="${name}-confirm" + 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"> + </b-input> + ${field.end_mapping()} + </div> + +</div> diff --git a/tailbone/templates/deform/date_jquery.pt b/tailbone/templates/deform/date_jquery.pt new file mode 100644 index 00000000..c55021b9 --- /dev/null +++ b/tailbone/templates/deform/date_jquery.pt @@ -0,0 +1,18 @@ +<!--! -*- mode: html; -*- --> +<div tal:define="css_class css_class|field.widget.css_class; + oid oid|field.oid; + field_name field_name|field.name; + style style|field.widget.style; + type_name type_name|field.widget.type_name;" + tal:omit-tag=""> + + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> + ${field.start_mapping()} + <tailbone-datepicker name="date" + id="${oid}" + v-model="${vmodel}"> + </tailbone-datepicker> + ${field.end_mapping()} + </div> + +</div> diff --git a/tailbone/templates/deform/date_plain.pt b/tailbone/templates/deform/date_plain.pt new file mode 100644 index 00000000..b473e422 --- /dev/null +++ b/tailbone/templates/deform/date_plain.pt @@ -0,0 +1,16 @@ +<div tal:define="css_class css_class|field.widget.css_class; + oid oid|field.oid; + style style|field.widget.style; + type_name type_name|field.widget.type_name;" + tal:omit-tag=""> + ${field.start_mapping()} + <input type="${type_name}" + name="date" + value="${cstruct}" + + tal:attributes="class string: ${css_class or ''} form-control hasDatepicker; + style style; + attributes|field.widget.attributes|{};" + id="${oid}"/> + ${field.end_mapping()} +</div> 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 new file mode 100644 index 00000000..af78eaf9 --- /dev/null +++ b/tailbone/templates/deform/file_upload.pt @@ -0,0 +1,44 @@ +<!--! -*- mode: html; -*- --> +<tal:block tal:define="oid oid|field.oid; + css_class css_class|field.widget.css_class; + style style|field.widget.style; + field_name field_name|field.name; + use_oruga use_oruga;"> + + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> + ${field.start_mapping()} + + <b-field class="file" + tal:condition="not use_oruga"> + <b-upload name="upload" + v-model="${vmodel}"> + <a class="button is-primary"> + <b-icon pack="fas" icon="upload"></b-icon> + <span>Click to upload</span> + </a> + </b-upload> + <span class="file-name" v-if="${vmodel}"> + {{ ${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> + +</tal:block> diff --git a/tailbone/templates/deform/message_recipients.pt b/tailbone/templates/deform/message_recipients.pt new file mode 100644 index 00000000..1ea5e153 --- /dev/null +++ b/tailbone/templates/deform/message_recipients.pt @@ -0,0 +1,13 @@ +<div tal:define="name name|field.name; + possible_recipients possible_recipients|'possibleRecipients'; + recipient_display_map recipient_display_map|'recipientDisplayMap';" + tal:omit-tag=""> + <div tal:define="vmodel vmodel|'field_model_' + name;" + tal:omit-tag=""> + <message-recipients name="${name}" + v-model="${vmodel}" + tal:attributes=":possible-recipients possible_recipients; + :recipient-display-map recipient_display_map;"> + </message-recipients> + </div> +</div> 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/numberinput.pt b/tailbone/templates/deform/numberinput.pt new file mode 100644 index 00000000..2204f40c --- /dev/null +++ b/tailbone/templates/deform/numberinput.pt @@ -0,0 +1,24 @@ +<span tal:define="name name|field.name; + css_class css_class|field.widget.css_class; + oid oid|field.oid; + mask mask|field.widget.mask; + mask_placeholder mask_placeholder|field.widget.mask_placeholder; + style style|field.widget.style; + autocomplete autocomplete|field.widget.autocomplete|'off'; +" + tal:omit-tag=""> + <input type="number" 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> +</span> diff --git a/tailbone/templates/deform/numericinput.pt b/tailbone/templates/deform/numericinput.pt new file mode 100644 index 00000000..4c54dd2c --- /dev/null +++ b/tailbone/templates/deform/numericinput.pt @@ -0,0 +1,12 @@ +<div tal:define="name name|field.name; + css_class css_class|field.widget.css_class; + oid oid|field.oid; + style style|field.widget.style; + vmodel vmodel|'field_model_' + name; + allow_enter allow_enter|field.widget.allow_enter;" + tal:omit-tag=""> + <numeric-input name="${name}" + v-model="${vmodel}" + tal:attributes=":allow-enter 'true' if allow_enter else 'false';"> + </numeric-input> +</div> diff --git a/tailbone/templates/deform/password.pt b/tailbone/templates/deform/password.pt new file mode 100644 index 00000000..b74d763a --- /dev/null +++ b/tailbone/templates/deform/password.pt @@ -0,0 +1,16 @@ +<span tal:define="name name|field.name; + css_class css_class|field.widget.css_class; + oid oid|field.oid; + mask mask|field.widget.mask; + mask_placeholder mask_placeholder|field.widget.mask_placeholder; + style style|field.widget.style;" + tal:omit-tag=""> + <div tal:define="vmodel vmodel|'field_model_' + name;" + tal:omit-tag=""> + <b-input name="${name}" + v-model="${vmodel}" + 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 new file mode 100644 index 00000000..d76e5848 --- /dev/null +++ b/tailbone/templates/deform/percentinput.pt @@ -0,0 +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';" + tal:omit-tag=""> + + <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 new file mode 100644 index 00000000..b32f36ea --- /dev/null +++ b/tailbone/templates/deform/permissions.pt @@ -0,0 +1,56 @@ +<div tal:define="oid oid|field.oid; + true_val true_val|field.widget.true_val;" + tal:omit-tag=""> + + <div> + ${field.start_mapping()} + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + showing group: + </div> + <div class="level-item"> + <!-- TODO: should make this v-model dynamic --> + <b-select v-model="showingPermissionGroup"> + <option value="">(all)</option> + <tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())"> + <option tal:attributes="value groupkey"> + ${permissions[groupkey]['label']} + </option> + </tal:loop> + </b-select> + </div> + </div> + </div> + + <tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())"> + <div tal:define="perms permissions[groupkey]['perms'];" + class="permissions-group"> + <!-- TODO: should use more dynamic v-model name --> + <div class="card" + tal:attributes="v-show string: !showingPermissionGroup || showingPermissionGroup == '${permissions[groupkey]['key']}';"> + <header class="card-header"> + <p class="card-header-title">${permissions[groupkey]['label']}</p> + </header> + <div class="card-content"> + <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><!-- card-content --> + </div><!-- card --> + </div><!-- permissions-group --> + </tal:loop> + + ${field.end_mapping()} + </div> +</div> 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 5bae0ecb..b033a8e2 100644 --- a/tailbone/templates/deform/select.pt +++ b/tailbone/templates/deform/select.pt @@ -6,36 +6,45 @@ css_class css_class|field.widget.css_class; unicode unicode|str; optgroup_class optgroup_class|field.widget.optgroup_class; - multiple multiple|field.widget.multiple;" + multiple multiple|field.widget.multiple; + input_handler input_handler|'';" tal:omit-tag=""> - <input type="hidden" name="__start__" value="${name}:sequence" - tal:condition="multiple" /> - <select tal:attributes=" - name name; - id oid; - class string: form-control ${css_class or ''}; - multiple multiple; - size size; - style style;" auto-enhance="true"> - <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" + <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; + id oid; + placeholder '(please choose)'; + class string: form-control ${css_class or ''}; + :multiple str(multiple).lower(); + native-size size; + style style; + v-model vmodel; + @input input_handler; + attributes|field.widget.attributes|{};"> + + <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, value); + selected python:field.widget.get_select_value(cstruct, item[0]); 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> - <input type="hidden" name="__end__" value="${name}:sequence" - tal:condition="multiple" /> + value item[0]">${item[1]}</option> + </tal:loop> + + </b-select> + <input type="hidden" name="__end__" value="${name}:sequence" + tal:condition="multiple" /> + </div> + </div> diff --git a/tailbone/templates/deform/select_dynamic.pt b/tailbone/templates/deform/select_dynamic.pt new file mode 100644 index 00000000..712830d1 --- /dev/null +++ b/tailbone/templates/deform/select_dynamic.pt @@ -0,0 +1,34 @@ +<!--! -*- mode: html; -*- --> +<div tal:define=" + name name|field.name; + oid oid|field.oid; + style style|field.widget.style; + size size|field.widget.size; + css_class css_class|field.widget.css_class; + unicode unicode|str; + optgroup_class optgroup_class|field.widget.optgroup_class; + multiple multiple|field.widget.multiple; + vmodel vmodel|'field_model_' + name; + input_handler input_handler|'';" + tal:omit-tag=""> + + <b-select tal:attributes="name name; + id oid; + placeholder '(please choose)'; + class string: form-control ${css_class or ''}; + multiple multiple; + size size; + style style; + v-model vmodel; + @input input_handler; + attributes|field.widget.attributes|{};"> + + <option v-for="item in ${name}_options" + tal:attributes=":key 'item.value'; + :value 'item.value';"> + <span v-html="item.label"></span> + </option> + + </b-select> + +</div> diff --git a/tailbone/templates/deform/select_jquery.pt b/tailbone/templates/deform/select_jquery.pt new file mode 100644 index 00000000..d276b4b7 --- /dev/null +++ b/tailbone/templates/deform/select_jquery.pt @@ -0,0 +1,52 @@ +<div tal:define=" + name name|field.name; + oid oid|field.oid; + style style|field.widget.style; + size size|field.widget.size; + css_class css_class|field.widget.css_class; + unicode unicode|str; + optgroup_class optgroup_class|field.widget.optgroup_class; + multiple multiple|field.widget.multiple;" + tal:omit-tag=""> + + <input type="hidden" name="__start__" value="${name}:sequence" + tal:condition="multiple" /> + <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> + <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> diff --git a/tailbone/templates/deform/select_plain.pt b/tailbone/templates/deform/select_plain.pt new file mode 100644 index 00000000..3ce4e1af --- /dev/null +++ b/tailbone/templates/deform/select_plain.pt @@ -0,0 +1,42 @@ +<div tal:define=" + name name|field.name; + oid oid|field.oid; + style style|field.widget.style; + size size|field.widget.size; + css_class css_class|field.widget.css_class; + unicode unicode|str; + optgroup_class optgroup_class|field.widget.optgroup_class; + multiple multiple|field.widget.multiple;" + tal:omit-tag=""> + + <input type="hidden" name="__start__" value="${name}:sequence" + tal:condition="multiple" /> + <select tal:attributes=" + name name; + id oid; + class string: form-control ${css_class or ''}; + multiple multiple; + size size; + style style; + attributes|field.widget.attributes|{};"> + <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> + <input type="hidden" name="__end__" value="${name}:sequence" + tal:condition="multiple" /> +</div> diff --git a/tailbone/templates/deform/textarea.pt b/tailbone/templates/deform/textarea.pt new file mode 100644 index 00000000..bb9b6c84 --- /dev/null +++ b/tailbone/templates/deform/textarea.pt @@ -0,0 +1,16 @@ +<div tal:define="rows rows|field.widget.rows; + cols cols|field.widget.cols; + css_class css_class|field.widget.css_class; + oid oid|field.oid; + name name|field.name; + style style|field.widget.style;" + tal:omit-tag=""> + + <div tal:define="vmodel vmodel|'field_model_' + name;"> + <b-input type="textarea" + name="${name}" + 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 new file mode 100644 index 00000000..47621654 --- /dev/null +++ b/tailbone/templates/deform/textinput.pt @@ -0,0 +1,19 @@ +<span tal:define="name name|field.name; + css_class css_class|field.widget.css_class; + oid oid|field.oid; + mask mask|field.widget.mask; + mask_placeholder mask_placeholder|field.widget.mask_placeholder; + style style|field.widget.style; + placeholder placeholder|getattr(field.widget, 'placeholder', ''); + autocomplete autocomplete|getattr(field.widget, 'autocomplete', 'on');" + tal:omit-tag=""> + <div tal:define="vmodel vmodel|'field_model_' + name;" + tal:omit-tag=""> + <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 new file mode 100644 index 00000000..8fa6cbe7 --- /dev/null +++ b/tailbone/templates/deform/time_jquery.pt @@ -0,0 +1,19 @@ +<!--! -*- mode: html; -*- --> +<span tal:define="size size|field.widget.size; + css_class css_class|field.widget.css_class; + oid oid|field.oid; + style style|field.widget.style|None; + type_name type_name|field.widget.type_name; + field_name field_name|field.name;" + tal:omit-tag=""> + + <div tal:define="vmodel vmodel|'field_model_' + field_name;"> + ${field.start_mapping()} + <tailbone-timepicker name="time" + id="${oid}" + v-model="${vmodel}"> + </tailbone-timepicker> + ${field.end_mapping()} + </div> + +</span> diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako index 573e0212..c5c39cbb 100644 --- a/tailbone/templates/departments/view.mako +++ b/tailbone/templates/departments/view.mako @@ -1,13 +1,9 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -${parent.body()} - -<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 +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n} + </script> +</%def> diff --git a/tailbone/templates/diff.mako b/tailbone/templates/diff.mako index 4b8888e6..a78bd770 100644 --- a/tailbone/templates/diff.mako +++ b/tailbone/templates/diff.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8; -*- -<table class="diff${' monospace' if diff.monospace else ''}"> +<table class="diff ${diff.nature} ${' monospace' if diff.monospace else ''}"> <thead> <tr> % for column in diff.columns: @@ -9,7 +9,7 @@ </thead> <tbody> % for field in diff.fields: - <tr${' class="diff"' if diff.values_differ(field) else ''|n}> + <tr ${diff.get_row_attrs(field)|n}> <td class="field">${diff.render_field(field)}</td> <td class="old-value">${diff.render_old_value(field)}</td> <td class="new-value">${diff.render_new_value(field)}</td> diff --git a/tailbone/templates/email-bounces/view.mako b/tailbone/templates/email-bounces/view.mako index 386a1919..f8372c88 100644 --- a/tailbone/templates/email-bounces/view.mako +++ b/tailbone/templates/email-bounces/view.mako @@ -1,50 +1,55 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="head_tags()"> - ${parent.head_tags()} +<%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> - <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="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="render_this_page()"> + ${parent.render_this_page()} + <pre class="email-message-body">${message}</pre> +</%def> + + ${parent.body()} - -<pre id="message"> -${message} -</pre> diff --git a/tailbone/templates/email/profiles/view.mako b/tailbone/templates/email/profiles/view.mako deleted file mode 100644 index 2e9f4325..00000000 --- a/tailbone/templates/email/profiles/view.mako +++ /dev/null @@ -1,31 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/view.mako" /> - -<%def name="head_tags()"> - ${parent.head_tags()} - <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> -</%def> - -${parent.body()} - -${h.form(url('email.preview'), name='send-email-preview')} - ${h.csrf_token(request)} - ${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()} 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/employees/view.mako b/tailbone/templates/employees/view.mako new file mode 100644 index 00000000..f9e54bd8 --- /dev/null +++ b/tailbone/templates/employees/view.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> +<%namespace file="/util.mako" import="view_profiles_helper" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + ${view_profiles_helper([instance.person])} +</%def> + +${parent.body()} diff --git a/tailbone/templates/feedback.mako b/tailbone/templates/feedback.mako deleted file mode 100644 index d9964f37..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=unicode(request.user)) + unicode(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 924e3a56..00000000 --- a/tailbone/templates/feedback_dialog.mako +++ /dev/null @@ -1,39 +0,0 @@ -## -*- coding: utf-8; -*- - -<%def name="feedback_dialog()"> - <div id="feedback-dialog"> - ${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/form.mako b/tailbone/templates/form.mako index 0f230dba..e3a4d5dc 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -1,14 +1,114 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> -<%def name="context_menu_items()"></%def> +<%def name="object_helpers()"></%def> -<div class="form-wrapper"> +<%def name="render_form_buttons()"></%def> - <ul class="context-menu"> - ${self.context_menu_items()} - </ul> +<%def name="render_form_template()"> + ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n} +</%def> - ${form.render()|n} +<%def name="render_form()"> + <div class="form"> + ${form.render_vue_tag()} + </div> +</%def> -</div> +<%def name="page_content()"> + % 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_form()} + </div> + % endif +</%def> + +<%def name="render_this_page()"> + <div style="display: flex; justify-content: space-between;"> + + <div class="this-page-content"> + ${self.page_content()} + </div> + + <div style="display: flex; align-items: flex-start;"> + + ${before_object_helpers()} + + <div class="object-helpers"> + ${self.object_helpers()} + </div> + + <ul id="context-menu"> + ${self.context_menu_items()} + </ul> + </div> + + </div> +</%def> + +<%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 +</%def> + +<%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> + +<%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 new file mode 100644 index 00000000..d566a467 --- /dev/null +++ b/tailbone/templates/formposter.mako @@ -0,0 +1,84 @@ +## -*- coding: utf-8; -*- + +<%def name="declare_formposter_mixin()"> + <script type="text/javascript"> + + let SimpleRequestMixin = { + methods: { + + simpleGET(url, params, success, failure) { + + 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, + } + + this.$http.post(action, params, {headers: headers}).then(response => { + + if (response.data.error) { + this.$buefy.toast.open({ + 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: "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 new file mode 100644 index 00000000..2100b460 --- /dev/null +++ b/tailbone/templates/forms/deform.mako @@ -0,0 +1,211 @@ +## -*- coding: utf-8; -*- + +<% request.register_component(form.vue_tagname, form.vue_component) %> + +<script type="text/x-template" id="${form.vue_tagname}-template"> + + <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 + + <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> + </nav> + % endfor + % else: + % for fieldname in form.fields: + ${form.render_vue_field(fieldname, session=session)} + % endfor + % endif + </section> + + % if buttons: + <br /> + ${buttons|n} + % elif not form.readonly and (buttons is Undefined or (buttons is not None and buttons is not False)): + <br /> + <div class="buttons"> + % if getattr(form, 'show_cancel', True): + % if form.auto_disable_cancel: + <once-button tag="a" href="${form.cancel_url or request.get_referrer()}" + text="Cancel"> + </once-button> + % else: + <b-button tag="a" href="${form.cancel_url or request.get_referrer()}"> + Cancel + </b-button> + % endif + % endif + % if getattr(form, 'show_reset', False): + <input type="reset" value="Reset" class="button" /> + % endif + ## TODO: deprecate / remove the latter option here + % if 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 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 + + ## 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/field_autocomplete.mako b/tailbone/templates/forms/field_autocomplete.mako deleted file mode 100644 index 17720c68..00000000 --- a/tailbone/templates/forms/field_autocomplete.mako +++ /dev/null @@ -1,4 +0,0 @@ -## -*- coding: utf-8 -*- -<%namespace file="/autocomplete.mako" import="autocomplete" /> - -${autocomplete(field_name, service_url, field_value, field_display, width=width, selected=selected, cleared=cleared)} diff --git a/tailbone/templates/forms/fields/date.mako b/tailbone/templates/forms/fields/date.mako deleted file mode 100644 index e74e90a8..00000000 --- a/tailbone/templates/forms/fields/date.mako +++ /dev/null @@ -1,10 +0,0 @@ -## -*- coding: utf-8 -*- -${h.text(name, value=value)} -<script type="text/javascript"> - $(function() { - $('input[name="${name}"]').datepicker({ - changeYear: ${'true' if change_year else 'false'}, - dateFormat: 'yy-mm-dd' - }); - }); -</script> diff --git a/tailbone/templates/forms/fieldset.mako b/tailbone/templates/forms/fieldset.mako deleted file mode 100644 index 98fc47d8..00000000 --- a/tailbone/templates/forms/fieldset.mako +++ /dev/null @@ -1,40 +0,0 @@ -## -*- coding: utf-8 -*- -<% _focus_rendered = False %> - -% for error in fieldset.errors.get(None, []): - <div class="fieldset-error">${error}</div> -% endfor - -% for field in fieldset.render_fields.itervalues(): - - % if field.requires_label: - <div class="field-wrapper ${field.name}"> - % for error in field.errors: - <div class="field-error">${error}</div> - % endfor - ${field.label_tag()|n} - <div class="field">${field.render()|n}</div> - % if 'instructions' in field.metadata: - <span class="instructions">${field.metadata['instructions']}</span> - % endif - </div> - - % if not _focus_rendered and (fieldset.focus is True or fieldset.focus is field): - % if not field.is_readonly() and getattr(field.renderer, 'needs_focus', True): - <script type="text/javascript"> - $(function() { - % if hasattr(field.renderer, 'focus_name'): - $('#${field.renderer.focus_name}').focus(); - % else: - $('#${field.renderer.name}').focus(); - % endif - }); - </script> - <% _focus_rendered = True %> - % endif - % endif - % else: - ${field.render()|n} - % endif - -% endfor diff --git a/tailbone/templates/forms/fieldset_readonly.mako b/tailbone/templates/forms/fieldset_readonly.mako deleted file mode 100644 index 58aef14c..00000000 --- a/tailbone/templates/forms/fieldset_readonly.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8 -*- -<%namespace file="/forms/lib.mako" import="render_field_readonly" /> -<div class="fieldset"> - % for field in fieldset.render_fields.itervalues(): - ${render_field_readonly(field)} - % endfor -</div> diff --git a/tailbone/templates/forms/form.mako b/tailbone/templates/forms/form.mako deleted file mode 100644 index 3baf812a..00000000 --- a/tailbone/templates/forms/form.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- coding: utf-8 -*- -<div class="form"> - ${h.form(form.action_url, id=form.id or None, method='post', enctype='multipart/form-data')} - ${form.csrf_token()} - - ${form.render_fields()|n} - - % if buttons: - ${buttons|n} - % else: - <div class="buttons"> - ${h.submit('create', form.create_label if form.creating else form.update_label)} - % if form.creating and form.allow_successive_creates: - ${h.submit('create_and_continue', form.successive_create_label)} - % endif - <a href="${form.cancel_url}" class="button">Cancel</a> - </div> - % endif - - ${h.end_form()} -</div> diff --git a/tailbone/templates/forms/form_readonly.mako b/tailbone/templates/forms/form_readonly.mako deleted file mode 100644 index c8aa0543..00000000 --- a/tailbone/templates/forms/form_readonly.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8 -*- -<div class="form"> - ${form.render_fields()|n} - % if buttons: - ${buttons|n} - % endif -</div> diff --git a/tailbone/templates/forms/lib.mako b/tailbone/templates/forms/lib.mako deleted file mode 100644 index 602d35c4..00000000 --- a/tailbone/templates/forms/lib.mako +++ /dev/null @@ -1,10 +0,0 @@ -## -*- coding: utf-8 -*- - -<%def name="render_field_readonly(field)"> - % if field.requires_label: - <div class="field-wrapper ${field.name}"> - ${field.label_tag()|n} - <div class="field">${field.render_readonly()}</div> - </div> - % endif -</%def> diff --git a/tailbone/templates/forms/vue_template.mako b/tailbone/templates/forms/vue_template.mako new file mode 100644 index 00000000..ac096f67 --- /dev/null +++ b/tailbone/templates/forms/vue_template.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/forms/deform.mako" /> +${parent.body()} diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms2/deform.mako deleted file mode 100644 index e9d0b1a5..00000000 --- a/tailbone/templates/forms2/deform.mako +++ /dev/null @@ -1,81 +0,0 @@ -## -*- coding: utf-8; -*- - -% if not readonly: -<% _focus_rendered = False %> -${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data')} -${h.csrf_token(request)} -% endif - -## % for error in fieldset.errors.get(None, []): -## <div class="fieldset-error">${error}</div> -## % endfor - -% for field in form.fields: - - ## % 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 field.requires_label: - <div class="field-wrapper ${field.name} ${'error' if field.error else ''}"> - ## % for error in field.errors: - ## <div class="field-error">${error}</div> - ## % endfor - % if field.error: - <div class="field-error">${field.error.msg}</div> - % endif - <label for="${field.oid}">${field.title}</label> - <div class="field"> - ${field.serialize()|n} - </div> - ## % if 'instructions' in field.metadata: - ## <span class="instructions">${field.metadata['instructions']}</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: - ## ${field.render()|n} - ## % endif - - % endif - -% endfor - -% if buttons: - ${buttons|n} -% elif not readonly: - <div class="buttons"> - ## ${h.submit('create', form.create_label if form.creating else form.update_label)} - ${h.submit('save', "Save")} -## % if form.creating and form.allow_successive_creates: -## ${h.submit('create_and_continue', form.successive_create_label)} -## % endif - ${h.link_to("Cancel", form.cancel_url, class_='button autodisable')} - </div> -% endif - -% if not readonly: -${h.end_form()} -% endif diff --git a/tailbone/templates/forms2/form.mako b/tailbone/templates/forms2/form.mako deleted file mode 100644 index 2ee96e3c..00000000 --- a/tailbone/templates/forms2/form.mako +++ /dev/null @@ -1,5 +0,0 @@ -## -*- coding: utf-8; -*- - -<div class="form"> - ${form.render_deform()|n} -</div> diff --git a/tailbone/templates/forms2/form_readonly.mako b/tailbone/templates/forms2/form_readonly.mako deleted file mode 100644 index ed61a44e..00000000 --- a/tailbone/templates/forms2/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/generate_feature.mako b/tailbone/templates/generate_feature.mako new file mode 100644 index 00000000..0f2a9f7b --- /dev/null +++ b/tailbone/templates/generate_feature.mako @@ -0,0 +1,387 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Generate Feature</%def> + +<%def name="content_title()"></%def> + +<%def name="page_content()"> + + <b-field horizontal label="App Prefix" + message="Unique naming prefix for the app."> + <b-input v-model="app.app_prefix" + @input="appPrefixChanged"> + </b-input> + </b-field> + + <b-field horizontal label="App CapPrefix" + message="Unique naming prefix for the app, in CapWords style."> + <b-input v-model="app.app_cap_prefix"></b-input> + </b-field> + + <b-field horizontal label="Feature Type"> + <b-select v-model="featureType"> + <option value="new-report">New Report</option> + <option value="new-table">New Table</option> + </b-select> + </b-field> + + <div v-if="featureType == 'new-table'"> + ${h.form(request.current_route_url(), ref='new-table-form')} + ${h.csrf_token(request)} + ${h.hidden('feature_type', value='new-table')} + ${h.hidden('app_prefix', **{'v-model': 'app.app_prefix'})} + ${h.hidden('app_cap_prefix', **{'v-model': 'app.app_cap_prefix'})} + ${h.hidden('columns', **{':value': 'JSON.stringify(new_table.columns)'})} + + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">New Table</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Table Name" + :message="`Name for the table within the DB. With prefix this becomes: ${'$'}{app.app_prefix}_${'$'}{new_table.table_name}`"> + <b-input name="table_name" + v-model="new_table.table_name" + @input="tableNameChanged"> + </b-input> + </b-field> + + <b-field horizontal label="Model Name" + :message="`Model name for the table, in CapWords style. With prefix this becomes: ${'$'}{app.app_cap_prefix}${'$'}{new_table.model_name}`"> + <b-input name="model_name" v-model="new_table.model_name"></b-input> + </b-field> + + <b-field horizontal label="Model Title" + message="Human-friendly singular model title."> + <b-input name="model_title" v-model="new_table.model_title"></b-input> + </b-field> + + <b-field horizontal label="Plural Model Title" + message="Human-friendly plural model title."> + <b-input name="model_title_plural" v-model="new_table.model_title_plural"></b-input> + </b-field> + + <b-field horizontal label="Description" + message="Description of what a record in this table represents."> + <b-input name="description" v-model="new_table.description"></b-input> + </b-field> + + <b-field horizontal label="Versioned" + message="Whether to record version data for this table."> + <b-checkbox name="versioned" + v-model="new_table.versioned" + native-value="true"> + {{ new_table.versioned }} + </b-checkbox> + </b-field> + + <b-field horizontal label="Columns"> + <div class="control"> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="plus" + @click="addColumn()"> + New Column + </b-button> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <b-button type="is-danger" + icon-pack="fas" + icon-left="trash" + @click="new_table.columns = []" + :disabled="!new_table.columns.length"> + Delete All + </b-button> + </div> + </div> + </div> + + <${b}-table + :data="new_table.columns"> + + <${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"> + {{ props.row.data_type }} + </${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" + v-slot="props"> + {{ props.row.description }} + </${b}-table-column> + + <${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)"> + % 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 + % if request.use_oruga: + v-model:active="showingEditColumn" + % else: + :active.sync="showingEditColumn" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Edit Column</p> + </header> + + <section class="modal-card-body"> + + <b-field label="Name"> + <b-input v-model="editingColumnName" + expanded /> + </b-field> + + <b-field label="Data Type"> + <b-input v-model="editingColumnDataType" + expanded /> + </b-field> + + <b-field label="Nullable"> + <b-checkbox v-model="editingColumnNullable" + native-value="true"> + {{ editingColumnNullable }} + </b-checkbox> + </b-field> + + <b-field label="Description"> + <b-input v-model="editingColumnDescription" + expanded /> + </b-field> + + </section> + + <footer class="modal-card-foot"> + <b-button @click="showingEditColumn = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="saveColumn()"> + Save + </b-button> + </footer> + </div> + </${b}-modal> + + </div> + </b-field> + + </div> + </div> + </div> + + ${h.end_form()} + </div> + + <div v-if="featureType == 'new-report'"> + ${h.form(request.current_route_url(), ref='new-report-form')} + ${h.csrf_token(request)} + ${h.hidden('feature_type', value='new-report')} + ${h.hidden('app_prefix', **{'v-model': 'app.app_prefix'})} + ${h.hidden('app_cap_prefix', **{'v-model': 'app.app_cap_prefix'})} + + <br /> + <div class="card"> + <header class="card-header"> + <p class="card-header-title">New Report</p> + </header> + <div class="card-content"> + <div class="content"> + + <b-field horizontal label="Name" + message="Human-friendly name for the report."> + <b-input name="name" v-model="new_report.name"></b-input> + </b-field> + + <b-field horizontal label="Description" + message="Description of the report."> + <b-input name="description" v-model="new_report.description"></b-input> + </b-field> + + </div> + </div> + </div> + + ${h.end_form()} + </div> + + <br /> + <div class="buttons" style="padding-left: 8rem;"> + <once-button type="is-primary" + @click="submitFeatureForm()" + text="Generate Feature"> + </once-button> + </div> + + <div class="card" + v-if="resultGenerated"> + <header class="card-header"> + <p class="card-header-title"> + <a name="instructions" href="#instructions">Please follow these instructions carefully.</a> + </p> + </header> + <div class="card-content"> + <div class="content result rendered-markdown">${rendered_result or ""|n}</div> + </div> + </div> + +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.featureType = ${json.dumps(feature_type)|n} + ThisPageData.resultGenerated = ${json.dumps(bool(result))|n} + + % if result: + ThisPage.mounted = function() { + location.href = '#instructions' + } + % endif + + ThisPageData.app = { + <% dform = app_form.make_deform_form() %> + % for field in dform: + ${field.name}: ${app_form.get_vuejs_model_value(field)|n}, + % endfor + } + + % for key, form in feature_forms.items(): + <% safekey = key.replace('-', '_') %> + ThisPageData.${safekey} = { + <% dform = feature_forms[key].make_deform_form() %> + % for field in dform: + ${field.name}: ${feature_forms[key].get_vuejs_model_value(field)|n}, + % endfor + } + % endfor + + ThisPage.methods.appPrefixChanged = function(prefix) { + let words = prefix.split('_') + let capitalized = [] + words.forEach(word => { + capitalized.push(word[0].toUpperCase() + word.substr(1)) + }) + + this.app.app_cap_prefix = capitalized.join('') + } + + ThisPage.methods.tableNameChanged = function(name) { + let words = name.split('_') + let capitalized = [] + words.forEach(word => { + capitalized.push(word[0].toUpperCase() + word.substr(1)) + }) + + this.new_table.model_name = capitalized.join('') + this.new_table.model_title = capitalized.join(' ') + this.new_table.model_title_plural = capitalized.join(' ') + 's' + this.new_table.description = `Represents a ${'$'}{this.new_table.model_title}.` + } + + ThisPageData.showingEditColumn = false + ThisPageData.editingColumn = null + ThisPageData.editingColumnIndex = null + ThisPageData.editingColumnName = null + ThisPageData.editingColumnDataType = null + ThisPageData.editingColumnNullable = null + ThisPageData.editingColumnDescription = null + + ThisPage.methods.addColumn = function(column) { + this.editingColumn = null + this.editingColumnIndex = null + this.editingColumnName = null + this.editingColumnDataType = null + this.editingColumnNullable = true + this.editingColumnDescription = null + this.showingEditColumn = true + } + + 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 + this.editingColumnDescription = column.description + this.showingEditColumn = true + } + + ThisPage.methods.saveColumn = function() { + if (this.editingColumn) { + column = this.new_table.columns[this.editingColumnIndex] + } else { + column = {} + this.new_table.columns.push(column) + } + column.name = this.editingColumnName + column.data_type = this.editingColumnDataType + column.nullable = this.editingColumnNullable + column.description = this.editingColumnDescription + this.showingEditColumn = false + } + + ThisPage.methods.deleteColumn = function(index) { + this.new_table.columns.splice(index, 1) + } + + ThisPage.methods.submitFeatureForm = function() { + let form = this.$refs[this.featureType + '-form'] + // TODO: why do we have to set this? hidden field value is blank?! + form['feature_type'].value = this.featureType + form.submit() + } + + </script> +</%def> 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 new file mode 100644 index 00000000..da9f2aae --- /dev/null +++ b/tailbone/templates/grids/b-table.mako @@ -0,0 +1,101 @@ +## -*- coding: utf-8; -*- +<${b}-table + :data="${data_prop}" + icon-pack="fas" + striped + hoverable + narrowed + % if paginated: + paginated + per-page="${per_page}" + % endif + % if vshow is not Undefined and vshow: + v-show="${vshow}" + % endif + % if loading is not Undefined and loading: + :loading="${loading}" + % endif + % if grid.default_sortkey: + :default-sort="['${grid.default_sortkey}', '${grid.default_sortdir}']" + % 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> + </span> + % 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"> + % 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 #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> + + % if show_footer is not Undefined and show_footer: + <template slot="footer"> + <b-field grouped position="is-right"> + <span class="control"> + {{ ${data_prop}.length.toLocaleString('en') }} records + </span> + </b-field> + </template> + % endif + +</${b}-table> 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 b841e52c..00000000 --- a/tailbone/templates/grids/filters.mako +++ /dev/null @@ -1,38 +0,0 @@ -## -*- coding: utf-8 -*- -<div class="newfilters"> - - ${form.begin(method='get')} - <input type="hidden" name="reset-to-default-filters" value="false" /> - <input type="hidden" name="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}> - ${form.checkbox('{0}-active'.format(filtr.key), class_='active', id='filter-active-{0}'.format(filtr.key), checked=filtr.active)} - <label for="filter-active-${filtr.key}">${filtr.label}</label> - <div class="inputs"> - ${form.filter_verb(filtr)} - ${form.filter_value(filtr)} - </div> - </div> - % endfor - </fieldset> - - <div class="buttons"> - ${form.tag('button', type='submit', id='apply-filters', c="Apply Filters")} - <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> - ${form.tag('button', type='button', id='default-filters', c="Default View")} - ${form.tag('button', type='button', id='clear-filters', c="No Filters")} - % if allow_save_defaults and request.user: - ${form.tag('button', type='button', id='save-defaults', c="Save Defaults")} - % endif - </div> - - ${form.end()} -</div><!-- newfilters --> diff --git a/tailbone/templates/grids/grid.mako b/tailbone/templates/grids/grid.mako deleted file mode 100644 index 283ba289..00000000 --- a/tailbone/templates/grids/grid.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- coding: utf-8; -*- -<div class="grid ${grid_class}"> - <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 4081e298..54e44d57 100644 --- a/tailbone/templates/home.mako +++ b/tailbone/templates/home.mako @@ -1,21 +1,7 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="wuttaweb:templates/home.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; - } - </style> +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} </%def> - -<div class="logo"> - ${h.image(image_url, "{} logo".format(capture(self.app_title)), id='logo', width=500)} - <h1>Welcome to ${self.app_title()}</h1> -</div> diff --git a/tailbone/templates/ifps-plu-codes/index.mako b/tailbone/templates/ifps-plu-codes/index.mako new file mode 100644 index 00000000..3f014343 --- /dev/null +++ b/tailbone/templates/ifps-plu-codes/index.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + <li>${h.link_to("Go to IFPS Website", 'https://www.ifpsglobal.com/PLU-Codes/PLU-codes-Search', target='_blank')}</li> +</%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/printer.mako b/tailbone/templates/labels/profiles/printer.mako index 3863f3ab..8d70534e 100644 --- a/tailbone/templates/labels/profiles/printer.mako +++ b/tailbone/templates/labels/profiles/printer.mako @@ -1,5 +1,5 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> <%def name="title()">Printer Settings</%def> @@ -9,42 +9,5 @@ <li>${h.link_to("Edit this Label Profile", url('labelprofiles.edit', uuid=profile.uuid))}</li> </%def> -<div class="form-wrapper"> - <ul class="context-menu"> - ${self.context_menu_items()} - </ul> - - <div class="form"> - - <div class="field-wrapper"> - <label>Label Profile</label> - <div class="field">${profile.description}</div> - </div> - - <div class="field-wrapper"> - <label>Printer Spec</label> - <div class="field">${profile.printer_spec}</div> - </div> - - ${h.form(request.current_route_url())} - ${h.csrf_token(request)} - - % for name, display in printer.required_settings.iteritems(): - <div class="field-wrapper"> - <label for="${name}">${display}</label> - <div class="field"> - ${h.text(name, value=profile.get_printer_setting(name))} - </div> - </div> - % endfor - - <div class="buttons"> - ${h.submit('update', "Update")} - <button type="button" onclick="location.href = '${url('labelprofiles.view', uuid=profile.uuid)}';">Cancel</button> - </div> - - ${h.end_form()} - </div> - -</div> +${parent.body()} diff --git a/tailbone/templates/labels/profiles/view.mako b/tailbone/templates/labels/profiles/view.mako index 35557fd8..b93570af 100644 --- a/tailbone/templates/labels/profiles/view.mako +++ b/tailbone/templates/labels/profiles/view.mako @@ -24,19 +24,24 @@ % endif </%def> +<%def name="page_content()"> + ${parent.page_content()} + + <% printer = instance.get_printer(request.rattail_config) %> + % if printer and printer.required_settings: + <h2>Printer Settings</h2> + + <div class="form"> + % for name, display in printer.required_settings.items(): + <div class="field-wrapper"> + <label>${display}</label> + <div class="field">${label_handler.get_printer_setting(instance, name) or ''}</div> + </div> + % endfor + </div> + + % endif +</%def> + + ${parent.body()} - -<% printer = instance.get_printer(request.rattail_config) %> -% if printer and printer.required_settings: - <h2>Printer Settings</h2> - - <div class="form"> - % for name, display in printer.required_settings.iteritems(): - <div class="field-wrapper"> - <label>${display}</label> - <div class="field">${instance.get_printer_setting(name) or ''}</div> - </div> - % endfor - </div> - -% endif diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index 93d62ef8..d2ea7828 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -1,46 +1,17 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.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> +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/auth/login.mako" /> +## TODO: this will not be needed with wuttaform <%def name="extra_styles()"> ${parent.extra_styles()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/login.css'))} + <style> + .card-content .buttons { + justify-content: right; + } + </style> </%def> -<%def name="logo()"> - ${h.image(image_url, "{} logo".format(capture(self.app_title)), id='logo', width=500)} +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} </%def> - -<%def name="login_form()"> - <div class="form"> - ${form.begin(**{'data-ajax': 'false'})} - ${form.hidden('referrer', value=referrer)} - ${form.csrf_token()} - - ${form.field_div('username', form.text('username'))} - ${form.field_div('password', form.password('password'))} - - <div class="buttons"> - ${form.submit('submit', "Login")} - <input type="reset" value="Reset" /> - </div> - - ${form.end()} - </div> -</%def> - -${self.logo()} - -${self.login_form()} - -% if request.rattail_config.demo(): - <p class="tips"> - Login with <strong>chuck / admin</strong> for full demo access - </p> -% endif 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 438421dc..4c7e4662 100644 --- a/tailbone/templates/master/clone.mako +++ b/tailbone/templates/master/clone.mako @@ -1,24 +1,50 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/form.mako" /> <%def name="title()">Clone ${model_title}: ${instance_title}</%def> -<br /> -<p>You are about to clone the following ${model_title} as a new record:</p> +<%def name="render_form()"> + <br /> + <b-notification :closable="false"> + You are about to clone the following ${model_title} as a new record: + </b-notification> + ${parent.render_form()} +</%def> -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> +<%def name="render_form_buttons()"> + <br /> + <b-notification :closable="false"> + Are you sure about this? + </b-notification> + <br /> -<br /> -<p>Are you sure about this?</p> -<br /> + ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} + ${h.hidden('clone', value='clone')} + <div class="buttons"> + <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> -${h.form(request.current_route_url(), class_='autodisable')} -${h.csrf_token(request)} -${h.hidden('clone', value='clone')} - <div class="buttons"> - ${h.link_to("Whoops, nevermind...", form.cancel_url, class_='button autodisable')} - ${h.submit('submit', "Yes, please clone away")} - </div> -${h.end_form()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + TailboneFormData.formSubmitting = false + TailboneFormData.submitButtonText = "Yes, please clone away" + + TailboneForm.methods.submitForm = function() { + this.formSubmitting = true + this.submitButtonText = "Working, please wait..." + } + + </script> +</%def> 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 da86cb0b..d7dcbbd8 100644 --- a/tailbone/templates/master/create.mako +++ b/tailbone/templates/master/create.mako @@ -1,44 +1,6 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/form.mako" /> -<%def name="title()">New ${model_title}</%def> +<%def name="title()">New ${model_title_plural if getattr(master, 'creates_multiple', False) else model_title}</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${self.disable_button_js()} - % 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="disable_button_js()"> - <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> -</%def> - -<%def name="context_menu_items()"></%def> - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> +${parent.body()} diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 85892e35..d2f517d9 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -1,58 +1,47 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> <%def name="title()">Delete ${model_title}: ${instance_title}</%def> -<%def name="head_tags()"> - ${parent.head_tags()} - <script type="text/javascript"> - $(function() { - - $('#confirm-delete').click(function() { - $(this).button('disable').button('option', 'label', "Deleting, please wait..."); - $(this).parents('form').submit(); - }); - - }); - </script> +<%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="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 request.has_perm('{}.create'.format(permission_prefix)): - <li>${h.link_to("Create a new {}".format(model_title), url('{}.create'.format(route_prefix)))}</li> - % endif -</%def> - -<%def name="confirmation()"> +<%def name="render_form_buttons()"> <br /> - <p>Are you sure about this?</p> + <b-notification type="is-danger" :closable="false"> + Are you sure about this? + </b-notification> <br /> - ${h.form(request.current_route_url())} + ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} ${h.csrf_token(request)} <div class="buttons"> - <a class="button" href="${form.cancel_url}">Whoops, nevermind...</a> - <button type="button" id="confirm-delete">Yes, please DELETE this data forever!</button> + <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> -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -<br /> -<p>You are about to delete the following ${model_title} and all associated data:</p> + ${form.vue_component}Data.formSubmitting = false -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> + ${form.vue_component}.methods.submitForm = function() { + this.formSubmitting = true + } -${self.confirmation()} + </script> +</%def> diff --git a/tailbone/templates/master/edit.mako b/tailbone/templates/master/edit.mako index 2cebde10..a03912e6 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -1,52 +1,8 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/master/form.mako" /> -<%def name="title()">Edit: ${instance_title}</%def> +<%def name="title()">${index_title} » ${instance_title} » Edit</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> +<%def name="content_title()">Edit: ${instance_title}</%def> - $(function() { - - $('form').submit(function() { - var submit = $(this).find('input[type="submit"]'); - if (submit.length) { - submit.button('disable').button('option', 'label', "Saving, please wait..."); - } - }); - - }); - </script> - % if dform is not Undefined: - % for field in dform: - <% resources = field.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 - % endfor - % 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 - % if master.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 master.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 -</%def> - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> +${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 new file mode 100644 index 00000000..17063c21 --- /dev/null +++ b/tailbone/templates/master/form.mako @@ -0,0 +1,30 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ## 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}?")) { + this.$refs.deleteObjectForm.submit() + } + } + + % endif + </script> + + % if form is not Undefined and hasattr(form, 'render_included_templates'): + ${form.render_included_templates()} + % endif + +</%def> diff --git a/tailbone/templates/master/import_file.mako b/tailbone/templates/master/import_file.mako new file mode 100644 index 00000000..0dd03754 --- /dev/null +++ b/tailbone/templates/master/import_file.mako @@ -0,0 +1,6 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="title()">Import ${model_title_plural} from ${importer_host_title}</%def> + +${parent.body()} diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index bc219b00..a2d26c60 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -6,128 +6,627 @@ ## include a "tools" section, just above the grid on the right. ## ## ############################################################################## -<%inherit file="/base.mako" /> +<%inherit file="/page.mako" /> <%def name="title()">${index_title}</%def> <%def name="content_title()"></%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} +<%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="page_content()"> + + % if download_results_path: + <b-notification type="is-info"> + Your download should start automatically, or you can + ${h.link_to("click here", '{}?filename={}'.format(url('{}.download_results'.format(route_prefix)), h.os.path.basename(download_results_path)))} + </b-notification> + % endif + + % if download_results_rows_path: + <b-notification type="is-info"> + 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)))} + </b-notification> + % endif + + ${self.render_grid_component()} + + % 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()} + % endif +</%def> + +<%def name="render_grid_component()"> + ${grid.render_vue_tag()} +</%def> + +############################## +## 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"> - $(function() { - $('.grid-wrapper').gridwrapper(); + % if getattr(master, 'supports_grid_totals', False): + ${grid.vue_component}Data.gridTotalsDisplay = null + ${grid.vue_component}Data.gridTotalsFetching = false - % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): + ${grid.vue_component}.methods.gridTotalsFetch = function() { + this.gridTotalsFetching = true - $('form[name="merge-things"] button').button('option', 'disabled', $('.grid tbody td.checkbox input:checked').length != 2); + 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 + }) + } - $('.grid-wrapper').on('click', 'tbody td.checkbox input', function() { - $('form[name="merge-things"] button').button('option', 'disabled', $('.grid tbody td.checkbox input:checked').length != 2); - }); + ${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: + ThisPage.methods.downloadResultsRedirect = function() { + location.href = '${url('{}.download_results'.format(route_prefix))}?filename=${h.os.path.basename(download_results_path)}'; + } + ThisPage.mounted = function() { + // we give this 1 second before attempting the redirect; otherwise + // the FontAwesome icons do not seem to load properly. so this way + // the page should fully render before redirecting + window.setTimeout(this.downloadResultsRedirect, 1000) + } + % endif - $('form[name="merge-things"]').submit(function() { - var uuids = []; - $('.grid tbody td.checkbox input:checked').each(function() { - uuids.push($(this).parents('tr:first').data('uuid')); - }); - if (uuids.length != 2) { - return false; - } - $(this).find('[name="uuids"]').val(uuids.toString()); - $(this).find('button') - .button('option', 'label', "Preparing to Merge...") - .button('disable'); - }); + ## maybe auto-redirect to download latest "rows for results" file + % 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)}'; + } + ThisPage.mounted = function() { + // we give this 1 second before attempting the redirect; otherwise + // the FontAwesome icons do not seem to load properly. so this way + // the page should fully render before redirecting + window.setTimeout(this.downloadResultsRowsRedirect, 1000) + } + % endif - % endif + % 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 request.session.pop('{}.results_xlsx.generated'.format(route_prefix), False): + ThisPage.mounted = function() { + location.href = '${url('{}.results_xlsx_download'.format(route_prefix))}'; + } + % endif - % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): - - $('form[name="bulk-delete"] button').click(function() { - var count = 0; - var match = /showing \d+ thru \d+ of (\S+)/.exec($('.pager .showing').text()); - if (match) { - count = match[1]; - } else { - alert("There don't seem to be any results to delete!"); - return; + ## delete single object + % 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 + form.action = url + form.submit() } - if (! confirm("You are about to delete " + count + " ${model_title_plural}.\n\nAre you sure?")) { + } + % endif + + ## download results + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): + + ${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.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)) { + excluded.push(field) + } + }, this) + return excluded + } + + ${grid.vue_component}.methods.downloadResultsExcludeFields = function() { + const selected = Array.from(this.downloadResultsIncludedFieldsSelected) + if (!selected) { return } - $(this).button('disable').button('option', 'label', "Deleting Results..."); - $('form[name="bulk-delete"]').submit(); - }); - % endif - }); + 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 included + // nb. excluded list will reflect this change too + index = this.downloadResultsFieldsIncluded.indexOf(field) + if (index >= 0) { + this.downloadResultsFieldsIncluded.splice(index, 1) + } + }) + } + + ${grid.vue_component}.methods.downloadResultsIncludeFields = function() { + const selected = Array.from(this.downloadResultsExcludedFieldsSelected) + if (!selected) { + return + } + + 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 included + // nb. excluded list will reflect this change too + this.downloadResultsFieldsIncluded.push(field) + }) + } + + ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() { + this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault) + this.downloadResultsFieldsMode = 'default' + } + + ${grid.vue_component}.methods.downloadResultsUseAllFields = function() { + this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable) + this.downloadResultsFieldsMode = 'all' + } + + ${grid.vue_component}.methods.downloadResultsSubmit = function() { + this.$refs.download_results_form.submit() + } + % endif + + ## download rows for results + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): + + ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false + ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results" + + ${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?")) { + this.downloadResultsRowsButtonDisabled = true + this.downloadResultsRowsButtonText = "Working, please wait..." + this.$refs.downloadResultsRowsForm.submit() + } + } + % endif + + ## enable / disable selected objects + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): + + ${grid.vue_component}Data.enableSelectedSubmitting = false + ${grid.vue_component}Data.enableSelectedText = "Enable Selected" + + ${grid.vue_component}.computed.enableSelectedDisabled = function() { + if (this.enableSelectedSubmitting) { + return true + } + if (!this.checkedRowUUIDs().length) { + return true + } + return false + } + + ${grid.vue_component}.methods.enableSelectedSubmit = function() { + let uuids = this.checkedRowUUIDs() + if (!uuids.length) { + alert("You must first select one or more objects to disable.") + return + } + if (! confirm("Are you sure you wish to ENABLE the " + uuids.length + " selected objects?")) { + return + } + + this.enableSelectedSubmitting = true + this.enableSelectedText = "Working, please wait..." + this.$refs.enable_selected_form.submit() + } + + ${grid.vue_component}Data.disableSelectedSubmitting = false + ${grid.vue_component}Data.disableSelectedText = "Disable Selected" + + ${grid.vue_component}.computed.disableSelectedDisabled = function() { + if (this.disableSelectedSubmitting) { + return true + } + if (!this.checkedRowUUIDs().length) { + return true + } + return false + } + + ${grid.vue_component}.methods.disableSelectedSubmit = function() { + let uuids = this.checkedRowUUIDs() + if (!uuids.length) { + alert("You must first select one or more objects to disable.") + return + } + if (! confirm("Are you sure you wish to DISABLE the " + uuids.length + " selected objects?")) { + return + } + + this.disableSelectedSubmitting = true + this.disableSelectedText = "Working, please wait..." + this.$refs.disable_selected_form.submit() + } + + % endif + + ## delete selected objects + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): + + ${grid.vue_component}Data.deleteSelectedSubmitting = false + ${grid.vue_component}Data.deleteSelectedText = "Delete Selected" + + ${grid.vue_component}.computed.deleteSelectedDisabled = function() { + if (this.deleteSelectedSubmitting) { + return true + } + if (!this.checkedRowUUIDs().length) { + return true + } + return false + } + + ${grid.vue_component}.methods.deleteSelectedSubmit = function() { + let uuids = this.checkedRowUUIDs() + if (!uuids.length) { + alert("You must first select one or more objects to disable.") + return + } + if (! confirm("Are you sure you wish to DELETE the " + uuids.length + " selected objects?")) { + return + } + + this.deleteSelectedSubmitting = true + this.deleteSelectedText = "Working, please wait..." + this.$refs.delete_selected_form.submit() + } + % endif + + % 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 + + % if getattr(master, 'mergeable', False) and master.has_perm('merge'): + + ${grid.vue_component}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" + ${grid.vue_component}Data.mergeFormSubmitting = false + + ${grid.vue_component}.methods.submitMergeForm = function() { + this.mergeFormSubmitting = true + this.mergeFormButtonText = "Working, please wait..." + } + % endif </script> </%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.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 +<%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> - -<%def name="grid_tools()"> - % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): - ${h.form(url('{}.merge'.format(route_prefix)), name='merge-things')} - ${h.csrf_token(request)} - ${h.hidden('uuids')} - <button type="submit">Merge 2 ${model_title_plural}</button> - ${h.end_form()} - % endif - % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): - ${h.form(url('{}.bulk_delete'.format(route_prefix)), name='bulk-delete')} - ${h.csrf_token(request)} - <button type="button">Delete Results</button> - ${h.end_form()} - % endif -</%def> - -## ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} - -${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} - -## <div class="grid-wrapper"> -## -## <table class="grid-header"> -## <tbody> -## <tr> -## -## <td class="filters" rowspan="2"> -## % if grid.filterable: -## ## TODO: should this be variable sometimes? -## ${grid.render_filters(allow_save_defaults=True)|n} -## % endif -## </td> -## -## <td class="menu"> -## <ul id="context-menu"> -## ${self.context_menu_items()} -## </ul> -## </td> -## </tr> -## -## <tr> -## <td class="tools"> -## <div class="grid-tools"> -## ${self.grid_tools()} -## </div><!-- grid-tools --> -## </td> -## </tr> -## -## </tbody> -## </table><!-- grid-header --> -## -## ${grid.render_grid()|n} -## -## </div><!-- grid-wrapper --> diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako index f7bd50ed..487d258d 100644 --- a/tailbone/templates/master/merge.mako +++ b/tailbone/templates/master/merge.mako @@ -1,39 +1,16 @@ ## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +<%inherit file="/page.mako" /> <%def name="title()">Merge 2 ${model_title_plural}</%def> -<%def name="head_tags()"> - ${parent.head_tags()} - <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> +<%def name="extra_styles()"> + ${parent.extra_styles()} <style type="text/css"> p { margin: 20px auto; } - p.warning { + p.warning, + p.warning strong { color: red; } a.merge-object { @@ -84,60 +61,123 @@ <li>${h.link_to("Back to {}".format(model_title_plural), url(route_prefix))}</li> </%def> -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> +<%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> + <merge-buttons></merge-buttons> +</%def> -${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()} +<%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;"> + <div class="level-left"> + + <div class="level-item"> + <a class="button" href="${index_url}">Whoops, nevermind</a> + </div> + + <div class="level-item"> + ${h.form(request.current_route_url(), **{'@submit': 'submitSwapForm'})} + ${h.csrf_token(request)} + ${h.hidden('uuids', value=f'{keeping_uuid},{removing_uuid}')} + <b-button native-type="submit" + :disabled="swapFormSubmitting"> + {{ swapFormButtonText }} + </b-button> + ${h.end_form()} + </div> + + <div class="level-item"> + ${h.form(request.current_route_url(), **{'@submit': 'submitMergeForm'})} + ${h.csrf_token(request)} + ${h.hidden('uuids', value=f'{removing_uuid},{keeping_uuid}')} + ${h.hidden('commit-merge', value='yes')} + <b-button type="is-primary" + native-type="submit" + :disabled="mergeFormSubmitting"> + {{ mergeFormButtonText }} + </b-button> + ${h.end_form()} + </div> + + </div> + </div> + </script> + <script> + + const MergeButtons = { + template: '#merge-buttons-template', + data() { + return { + swapFormButtonText: "Swap which ${model_title} is kept/removed", + swapFormSubmitting: false, + mergeFormButtonText: "Yes, perform this merge", + mergeFormSubmitting: false, + } + }, + methods: { + submitSwapForm() { + this.swapFormSubmitting = true + this.swapFormButtonText = "Swapping, please wait..." + }, + submitMergeForm() { + this.mergeFormSubmitting = true + this.mergeFormButtonText = "Merging, please wait..." + }, + } + } + + </script> +</%def> + +<%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 c3784c24..a6bb14f0 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -4,22 +4,28 @@ ## Default master 'versions' template, for showing an object's version history. ## ## ############################################################################## -<%inherit file="/base.mako" /> +<%inherit file="/page.mako" /> <%def name="title()">${model_title_plural} » ${instance_title} » history</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} - <script type="text/javascript"> - $(function() { - $('.grid-wrapper').gridwrapper(); - }); - </script> -</%def> - <%def name="content_title()"> - <h1>History for ${instance_title}</h1> + Version History </%def> -${grid.render_complete()|n} +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + +<%def name="page_content()"> + ${grid.render_vue_tag(**{':csrftoken': 'csrftoken'})} +</%def> + +<%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 547d70ae..118c028c 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -1,79 +1,336 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/master/form.mako" /> -<%def name="title()">${model_title_plural} » ${instance_title}</%def> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - % if master.has_rows: - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} - <script type="text/javascript"> - $(function() { - $('.grid-wrapper').gridwrapper(); - }); - </script> - % endif -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - % if master.has_rows: - <style type="text/css"> - .grid-wrapper { - margin-top: 10px; - } - </style> - % endif -</%def> +<%def name="title()">${index_title} » ${instance_title}</%def> <%def name="content_title()"> - % if master.supports_prev_next: - <div style="float: right;"> - % if prev_instance: - ${h.link_to(u"« Older", url('{}.view'.format(route_prefix), uuid=prev_instance.uuid), class_='button autodisable')} + ${instance_title} +</%def> + +<%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: - ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} + <span><i class="fa fa-hand-pointer"></i></span> % endif - % if next_instance: - ${h.link_to(u"Newer »", url('{}.view'.format(route_prefix), uuid=next_instance.uuid), class_='button autodisable')} - % else: - ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} + </b-button> + % endif + % if expose_versions: + <b-button icon-pack="fas" + icon-left="history" + @click="viewingHistory = !viewingHistory"> + {{ viewingHistory ? "View Current" : "View History" }} + </b-button> + % endif +</%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()"> + <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 - <h1>${instance_title}</h1> </%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_row_grid_component()"> + ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')} +</%def> + +<%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 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> - % endif - % if master.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 master.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 - % 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.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> + % if expose_versions: + ${versions_grid.render_vue_template()} % endif </%def> -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> + % if getattr(master, 'touchable', False) and master.has_perm('touch'): -% if master.has_rows: - ${rows_grid|n} -% endif + 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> +</%def> + +<%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 bd5a52d3..623a33a0 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -1,29 +1,21 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/view.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> <%def name="title()">${model_title}</%def> <%def name="content_title()"> - <h1>${instance}</h1> + ${row_title} </%def> <%def name="context_menu_items()"> - <li>${h.link_to("Back to {}".format(parent_model_title), index_url)}</li> + <li>${h.link_to("Back to {}".format(parent_model_title), instance_url)}</li> % 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 </%def> -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> +${parent.body()} diff --git a/tailbone/templates/master/view_version.mako b/tailbone/templates/master/view_version.mako index 6014f1e7..dfe03a64 100644 --- a/tailbone/templates/master/view_version.mako +++ b/tailbone/templates/master/view_version.mako @@ -1,159 +1,58 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/page.mako" /> -<%def name="title()">${instance_title_normal} @ ver ${transaction.id}</%def> - -## TODO: this was basically copied from Revel diff template..need to abstract +<%def name="title()">changes @ ver ${transaction.id}</%def> <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - table { - background-color: White; - border-collapse: collapse; - border-left: 1px solid black; - border-top: 1px solid black; - font-size: 11pt; - margin-top: 2em; - margin-left: 50px; - min-width: 80%; - } - - table th, - table td { - border-bottom: 1px solid black; - border-right: 1px solid black; - } - - table td { - padding: 5px 10px; - } - - table td.value { - font-family: monospace; - white-space: pre; - } - - - table.new td.host-value, - table.diff tr.diff td.host-value { - background-color: #cfc; + .this-page-content { + overflow: auto; } - table.deleted td.local-value, - table.diff tr.diff td.local-value { - background-color: #fcc; + .versions-wrapper { + margin-left: 2rem; } </style> </%def> -<%def name="content_title()"> - <div style="float: right;"> - % if previous_transaction: - ${h.link_to(u"« Older", url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=previous_transaction.id), class_='button')} - % else: - ${h.link_to(u"« Older", '#', class_='button', disabled='disabled')} - % endif - % if next_transaction: - ${h.link_to(u"Newer »", url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=next_transaction.id), class_='button')} - % else: - ${h.link_to(u"Newer »", '#', class_='button', disabled='disabled')} - % endif +<%def name="page_content()"> + + <div class="form-wrapper" style="margin: 1rem; 0;"> + <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> + </div> + + <div class="versions-wrapper"> + % for diff in version_diffs: + <h4 class="is-size-4 block">${diff.title}</h4> + ${diff.render_html()} + % endfor </div> - <h1>${self.title()}</h1> </%def> -<div class="form-wrapper"> - <div class="form"> - - <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 --> - -% for version in versions: - - <h2>${title_for_version(version)}</h2> - - % if version.previous and version.operation_type == continuum.Operation.DELETE: - <table class="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 local-value">${repr(getattr(version.previous, field))}</td> - <td class="value host-value"> </td> - </tr> - % endfor - </tbody> - </table> - % elif version.previous: - <table class="diff"> - <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 local-value">${repr(getattr(version.previous, field))}</td> - <td class="value host-value">${repr(getattr(version, field))}</td> - </tr> - % endfor - </tbody> - </table> - % else: - <table class="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 local-value"> </td> - <td class="value host-value">${repr(getattr(version, field))}</td> - </tr> - % endfor - </tbody> - </table> - % endif - -% endfor +${parent.body()} 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 new file mode 100644 index 00000000..071e37d3 --- /dev/null +++ b/tailbone/templates/members/view.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> +<%namespace file="/util.mako" import="view_profiles_helper" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + ${view_profiles_helper(show_profiles_people)} +</%def> + +${parent.body()} diff --git a/tailbone/templates/menu.mako b/tailbone/templates/menu.mako new file mode 100644 index 00000000..65acd0dd --- /dev/null +++ b/tailbone/templates/menu.mako @@ -0,0 +1,49 @@ +## -*- coding: utf-8; -*- + +<%def name="main_menu_items()"> + + % for topitem in menus: + <li> + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'])} + % else: + <a>${topitem['title']}</a> + <ul> + % for subitem in topitem['items']: + % if subitem['is_sep']: + <li>-</li> + % else: + <li>${h.link_to(subitem['title'], subitem['url'], target=subitem['target'])}</li> + % endif + % endfor + </ul> + % endif + </li> + % endfor + + ## User Menu + % if request.user: + <li> + % if messaging_enabled: + <a${' class="root-user"' if request.is_root else ''|n}>${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> + % else: + <a${' class="root-user"' if request.is_root else ''|n}>${request.user}</a> + % endif + <ul> + % if request.is_root: + <li class="root-user">${h.link_to("Stop being root", url('stop_root'))}</li> + % elif request.is_admin: + <li class="root-user">${h.link_to("Become root", url('become_root'))}</li> + % endif + % if messaging_enabled: + <li>${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'))}</li> + % endif + <li>${h.link_to("Change Password", url('change_password'))}</li> + <li>${h.link_to("Logout", url('logout'))}</li> + </ul> + </li> + % else: + <li>${h.link_to("Login", url('login'))}</li> + % endif + +</%def> diff --git a/tailbone/templates/messages/archive/index.mako b/tailbone/templates/messages/archive/index.mako index 158780cf..16a05ee2 100644 --- a/tailbone/templates/messages/archive/index.mako +++ b/tailbone/templates/messages/archive/index.mako @@ -3,26 +3,11 @@ <%def name="title()">Message Archive</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - destination = "Inbox"; - </script> -</%def> - <%def name="context_menu_items()"> ${parent.context_menu_items()} <li>${h.link_to("Go to my Message Inbox", url('messages.inbox'))}</li> <li>${h.link_to("Go to my Sent Messages", url('messages.sent'))}</li> </%def> -<%def name="grid_tools()"> - ${h.form(url('messages.move_bulk'), name='move-selected')} - ${h.csrf_token(request)} - ${h.hidden('destination', value='inbox')} - ${h.hidden('uuids')} - <button type="submit">Move 0 selected to Inbox</button> - ${h.end_form()} -</%def> ${parent.body()} diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index c434a194..39236f75 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -1,129 +1,29 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/create.mako" /> +<%namespace file="/messages/recipients.mako" import="message_recipients_template" /> + +<%def name="content_title()"></%def> <%def name="extra_javascript()"> ${parent.extra_javascript()} - ${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, (uuid, entry) in enumerate(sorted(available_recipients.iteritems(), key=lambda r: r[1]), 1): - ['${uuid}', ${json.dumps(entry)|n}]${',' if i < last else ''} - % endfor - ]); - - // validate message before sending - function validate_message_form() { - var form = $('#new-message'); - - if (! form.find('input[name="Message--recipients"]').val()) { - alert("You must specify some recipient(s) for the message."); - $('.recipients input').data('ui-tagit').tagInput.focus(); - return false; - } - - if (! form.find('input[name="Message--subject"]').val()) { - alert("You must provide a subject for the message."); - form.find('input[name="Message--subject"]').focus(); - return false; - } - - return true; - } - - $(function() { - - var recipients = $('.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()} -</%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()} - ${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> </%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()"> % if request.has_perm('messages.list'): <li>${h.link_to("Go to my Message Inbox", url('messages.inbox'))}</li> @@ -132,4 +32,30 @@ % endif </%def> -${parent.body()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${message_recipients_template()} +</%def> + +<%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> diff --git a/tailbone/templates/messages/inbox/index.mako b/tailbone/templates/messages/inbox/index.mako index 331f0456..2ac24b9e 100644 --- a/tailbone/templates/messages/inbox/index.mako +++ b/tailbone/templates/messages/inbox/index.mako @@ -1,28 +1,13 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/messages/index.mako" /> <%def name="title()">Message Inbox</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - destination = "Archive"; - </script> -</%def> - <%def name="context_menu_items()"> ${parent.context_menu_items()} <li>${h.link_to("Go to my Message Archive", url('messages.archive'))}</li> <li>${h.link_to("Go to my Sent Messages", url('messages.sent'))}</li> </%def> -<%def name="grid_tools()"> - ${h.form(url('messages.move_bulk'), name='move-selected')} - ${h.csrf_token(request)} - ${h.hidden('destination', value='archive')} - ${h.hidden('uuids')} - <button type="submit">Move 0 selected to Archive</button> - ${h.end_form()} -</%def> ${parent.body()} diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako index d948593d..eaa4b6c9 100644 --- a/tailbone/templates/messages/index.mako +++ b/tailbone/templates/messages/index.mako @@ -1,54 +1,48 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <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> -</%def> - <%def name="context_menu_items()"> % if request.has_perm('messages.create'): <li>${h.link_to("Send a new Message", url('messages.create'))}</li> % endif </%def> -${parent.body()} +<%def name="grid_tools()"> + % if request.matched_route.name in ('messages.inbox', 'messages.archive'): + ${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_vue_vars()"> + ${parent.modify_vue_vars()} + % if request.matched_route.name in ('messages.inbox', 'messages.archive'): + <script> + + ${grid.vue_component}Data.moveMessagesSubmitting = false + ${grid.vue_component}Data.moveMessagesText = null + + ${grid.vue_component}.computed.moveMessagesTextCurrent = function() { + if (this.moveMessagesText) { + return this.moveMessagesText + } + let count = this.checkedRows.length + return "Move " + count.toString() + " selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}" + } + + ${grid.vue_component}.methods.moveMessagesSubmit = function() { + this.moveMessagesSubmitting = true + this.moveMessagesText = "Working, please wait..." + } + + </script> + % endif +</%def> diff --git a/tailbone/templates/messages/recipients.mako b/tailbone/templates/messages/recipients.mako new file mode 100644 index 00000000..c16ddaf2 --- /dev/null +++ b/tailbone/templates/messages/recipients.mako @@ -0,0 +1,36 @@ +## -*- coding: utf-8; -*- + +<%def name="message_recipients_template()"> + <script type="text/x-template" id="message-recipients-template"> + <div> + + <input type="hidden" :name="name" v-model="actualValue" /> + + <b-field grouped group-multiline> + <div v-for="uuid in actualValue" + :key="uuid" + class="control"> + <b-tag type="is-primary" + attached + aria-close-label="Remove recipient" + closable + @close="removeRecipient(uuid)"> + {{ recipientDisplayMap[uuid] }} + </b-tag> + </div> + </b-field> + + <b-autocomplete v-model="autocompleteValue" + placeholder="add recipient" + :data="filteredData" + field="uuid" + @select="selectionMade" + keep-first> + <template slot-scope="props"> + {{ props.option.label }} + </template> + <template slot="empty">No results found</template> + </b-autocomplete> + </div> + </script> +</%def> diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako index 966961ee..36418698 100644 --- a/tailbone/templates/messages/view.mako +++ b/tailbone/templates/messages/view.mako @@ -1,47 +1,18 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <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> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - .field-wrapper.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> </%def> @@ -51,7 +22,7 @@ <li>${h.link_to("Send a new Message", url('messages.create'))}</li> % endif % if recipient: - % if recipient.status == rattail.enum.MESSAGE_STATUS_INBOX: + % if recipient.status == enum.MESSAGE_STATUS_INBOX: <li>${h.link_to("Back to Message Inbox", url('messages.inbox'))}</li> <li>${h.link_to("Go to my Message Archive", url('messages.archive'))}</li> <li>${h.link_to("Go to my Sent Messages", url('messages.sent'))}</li> @@ -69,17 +40,29 @@ <%def name="message_tools()"> % if recipient: - <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 == rattail.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> + <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> @@ -87,12 +70,31 @@ ${instance.body} </%def> -${parent.body()} +<%def name="page_content()"> + ${parent.page_content()} + <br /> + <div style="margin-left: 5rem;"> + ${self.message_tools()} + <div class="tailbone-message-body"> + ${self.message_body()} + </div> + ${self.message_tools()} + </div> +</%def> -${self.message_tools()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -<div class="message-body"> - ${self.message_body()} -</div> + ${form.vue_component}Data.showingAllRecipients = false -${self.message_tools()} + ${form.vue_component}.methods.showMoreRecipients = function() { + this.showingAllRecipients = true + } + + ${form.vue_component}.methods.hideMoreRecipients = function() { + this.showingAllRecipients = false + } + + </script> +</%def> diff --git a/tailbone/templates/mobile/about.mako b/tailbone/templates/mobile/about.mako deleted file mode 100644 index ca0e2612..00000000 --- a/tailbone/templates/mobile/about.mako +++ /dev/null @@ -1,12 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">About ${self.app_title()}</%def> - -<h2>${project_title} ${project_version}</h2> - -% for name, version in packages.iteritems(): - <h3>${name} ${version}</h3> -% endfor - -<p>Please see <a href="https://rattailproject.org/">rattailproject.org</a> for more info.</p> diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako deleted file mode 100644 index dd5cfa11..00000000 --- a/tailbone/templates/mobile/base.mako +++ /dev/null @@ -1,126 +0,0 @@ -## -*- coding: utf-8 -*- -<!DOCTYPE html> -<html> - <head> - <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> - <title>${self.global_title()} » ${self.title()}</title> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - - ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} - ${h.javascript_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js')} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.mobile.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.js'))} - ${self.extra_javascript()} - - ${h.stylesheet_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css')} - ${h.stylesheet_link(request.static_url('tailbone:static/css/mobile.css'))} - % if not request.rattail_config.production(): - <style type="text/css"> - .ui-page-theme-a { background-image: url(${request.static_url('tailbone:static/img/testing.png')}); } - </style> - % endif - ${self.extra_styles()} - - </head> - ${self.mobile_body()} -</html> - -<%def name="mobile_body()"> - <body> - - ## note that our toolbars are *external* (in jqm-speak) by default - - ${self.mobile_header()} - - <div data-role="page" data-url="${self.page_url()}"${' data-rel="dialog"' if dialog else ''|n}> - - ${self.mobile_usermenu()} - - ${self.mobile_page_body()} - - </div><!-- page --> - - ${self.mobile_footer()} - - </body> -</%def> - -<%def name="app_title()">Rattail Demo</%def> - -<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def> - -<%def name="page_url()">${request.current_route_url()}</%def> - -<%def name="page_title()">${self.title()}</%def> - -<%def name="extra_javascript()"></%def> - -<%def name="extra_styles()"></%def> - -<%def name="mobile_header()"> - <div data-role="header"> - ${self.mobile_header_link()} - <h1>${self.global_title()}</h1> - </div> -</%def> - -<%def name="mobile_header_link()"> - <% classes = 'ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ' %> - % if request.user: - ${h.link_to(request.user.get_short_name(), '#usermenu', class_=classes + 'ui-icon-user' + (' root-user' if request.is_root else ''))} - % elif request.matched_route.name in ('mobile.login', 'mobile.about'): - ${h.link_to("Home", url('mobile.home'), class_=classes + 'ui-icon-home')} - % else: - ${h.link_to("Login", url('mobile.login'), class_=classes + 'ui-icon-user')} - % endif -</%def> - -<%def name="mobile_usermenu()"> - <div id="usermenu" data-role="panel" data-display="overlay"> - <ul data-role="listview"> - <li data-icon="home">${h.link_to("Home", url('mobile.home'))}</li> - % if request.has_perm('datasync.restart'): - <li>${h.link_to("DataSync", url('datasync.mobile'))}</li> - % endif - % if request.is_root: - <li class="root-user" data-icon="forbidden">${h.link_to("Stop being root", url('stop_root'), **{'data-ajax': 'false'})}</li> - % elif request.is_admin: - <li class="root-user" data-icon="forbidden">${h.link_to("Become root", url('become_root'), **{'data-ajax': 'false'})}</li> - % endif - <li data-icon="lock">${h.link_to("Logout", url('logout'), **{'data-ajax': 'false'})}</li> - <li data-icon="info">${h.link_to("About {}".format(capture(self.app_title)), url('mobile.about'))}</li> - </ul> - </div> -</%def> - -<%def name="mobile_page_body()"> - <div role="main" class="ui-content" data-route="${request.matched_route.name}"> - - % if request.session.peek_flash('error'): - % for error in request.session.pop_flash('error'): - <div class="error">${error}</div> - % endfor - % endif - - % if request.session.peek_flash(): - % for msg in request.session.pop_flash(): - <div class="flash">${msg|n}</div> - % endfor - % endif - - <h2>${self.page_title()}</h2> - - ${self.body()} - - <div class="replacement-header"> - ${self.mobile_header_link()} - </div> - - </div> -</%def> - -<%def name="mobile_footer()"> - <div data-role="footer"> - <h4>powered by ${h.link_to("Rattail", url('mobile.about'))}</h4> - </div> -</%def> diff --git a/tailbone/templates/mobile/base_internal_toolbars.mako b/tailbone/templates/mobile/base_internal_toolbars.mako deleted file mode 100644 index 107ca928..00000000 --- a/tailbone/templates/mobile/base_internal_toolbars.mako +++ /dev/null @@ -1,20 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="tailbone:templates/mobile/base.mako" /> - -<%def name="mobile_body()"> - <body> - - <div data-role="page" data-url="${self.page_url()}"${' data-rel="dialog"' if dialog else ''|n}> - - ${self.mobile_usermenu()} - - ${self.mobile_header()} - - ${self.mobile_page_body()} - - ${self.mobile_footer()} - - </div><!-- page --> - - </body> -</%def> diff --git a/tailbone/templates/mobile/batch/inventory/create.mako b/tailbone/templates/mobile/batch/inventory/create.mako deleted file mode 100644 index 99c8106d..00000000 --- a/tailbone/templates/mobile/batch/inventory/create.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/create.mako" /> - -<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » New Batch</%def> - -${parent.body()} diff --git a/tailbone/templates/mobile/batch/inventory/index.mako b/tailbone/templates/mobile/batch/inventory/index.mako deleted file mode 100644 index 29038208..00000000 --- a/tailbone/templates/mobile/batch/inventory/index.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/index.mako" /> - -<%def name="title()">Inventory</%def> - -${parent.body()} diff --git a/tailbone/templates/mobile/batch/inventory/view.mako b/tailbone/templates/mobile/batch/inventory/view.mako deleted file mode 100644 index 2c8f785c..00000000 --- a/tailbone/templates/mobile/batch/inventory/view.mako +++ /dev/null @@ -1,24 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/batch/view.mako" /> - -<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » ${batch.id_str}</%def> - -${form.render()|n} - -% if not batch.executed and not batch.complete: - <br /> - ${h.text('upc-search', class_='inventory-upc-search', placeholder="Enter UPC", autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.batch.inventory.row_from_upc', uuid=batch.uuid)})} -% endif - -% if master.has_rows: - <br /> - ${grid.render_complete()|n} -% endif - -% if not batch.executed and not batch.complete: - <br /> - ${h.form(request.route_url('mobile.batch.inventory.mark_complete', uuid=batch.uuid))} - ${h.csrf_token(request)} - ${h.hidden('mark-complete', value='true')} - <button type="submit">Mark Batch as Complete</button> -% endif diff --git a/tailbone/templates/mobile/batch/inventory/view_row.mako b/tailbone/templates/mobile/batch/inventory/view_row.mako deleted file mode 100644 index 5ef6165a..00000000 --- a/tailbone/templates/mobile/batch/inventory/view_row.mako +++ /dev/null @@ -1,64 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/batch/view_row.mako" /> -<%namespace file="/mobile/keypad.mako" import="keypad" /> - -## TODO: this is broken for actual page (header) title -<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} » ${h.link_to(instance.batch.id_str, url('mobile.batch.inventory.view', uuid=instance.batch_uuid))} » ${row.upc.pretty()}</%def> - -<% - unit_uom = 'LB' if row.product and row.product.weighed else 'EA' - - if row.cases: - uom = 'CS' - elif row.units: - uom = 'EA' - elif row.case_quantity: - uom = 'CS' - else: - uom = 'EA' -%> - -<div class="ui-grid-a"> - <div class="ui-block-a"> - % if instance.product: - <h3>${row.brand_name or ""}</h3> - <h3>${row.description} ${row.size}</h3> - <h3>${h.pretty_quantity(row.case_quantity)} ${unit_uom} per CS</h3> - % else: - <h3>${row.description}</h3> - % endif - </div> - <div class="ui-block-b"> - ${h.image(product_image_url, "product image")} - </div> -</div> - -<p> - currently: - % if uom == 'CS': - ${h.pretty_quantity(row.cases or 0)} - % else: - ${h.pretty_quantity(row.units or 0)} - % endif - ${uom} -</p> - -% if not row.batch.executed and not row.batch.complete: - - ${h.form(request.current_route_url())} - ${h.csrf_token(request)} - ${h.hidden('row', value=row.uuid)} - ${h.hidden('cases')} - ${h.hidden('units')} - - ${keypad(unit_uom, uom, quantity=row.cases or row.units or 1)} - - <fieldset data-role="controlgroup" data-type="horizontal" class="inventory-actions"> - <button type="button" class="ui-btn-inline ui-corner-all save">Save</button> - <button type="button" class="ui-btn-inline ui-corner-all delete" disabled="disabled">Delete</button> - ${h.link_to("Cancel", url('mobile.batch.inventory.view', uuid=row.batch.uuid), class_='ui-btn ui-btn-inline ui-corner-all')} - </fieldset> - - ${h.end_form()} - -% endif diff --git a/tailbone/templates/mobile/batch/view.mako b/tailbone/templates/mobile/batch/view.mako deleted file mode 100644 index 0770e703..00000000 --- a/tailbone/templates/mobile/batch/view.mako +++ /dev/null @@ -1,28 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view.mako" /> - -${parent.body()} - -% if master.has_rows: - % if master.mobile_rows_creatable and not batch.executed and not batch.complete: - ${h.link_to("Add Item", url('mobile.{}.create_row'.format(route_prefix), uuid=instance.uuid), class_='ui-btn ui-corner-all')} - % endif - <br /> - ${grid.render_complete()|n} -% endif - -% if not batch.executed and request.has_perm('{}.edit'.format(permission_prefix)): - % if batch.complete: - ${h.form(request.route_url('mobile.{}.mark_pending'.format(route_prefix), uuid=batch.uuid))} - ${h.csrf_token(request)} - ${h.hidden('mark-pending', value='true')} - ${h.submit('submit', "Mark Batch as Pending")} - ${h.end_form()} - % else: - ${h.form(request.route_url('mobile.{}.mark_complete'.format(route_prefix), uuid=batch.uuid))} - ${h.csrf_token(request)} - ${h.hidden('mark-complete', value='true')} - ${h.submit('submit', "Mark Batch as Complete")} - ${h.end_form()} - % endif -% endif diff --git a/tailbone/templates/mobile/batch/view_row.mako b/tailbone/templates/mobile/batch/view_row.mako deleted file mode 100644 index ad729169..00000000 --- a/tailbone/templates/mobile/batch/view_row.mako +++ /dev/null @@ -1,4 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view_row.mako" /> - -${parent.body()} diff --git a/tailbone/templates/mobile/datasync.mako b/tailbone/templates/mobile/datasync.mako deleted file mode 100644 index 2f21a2a2..00000000 --- a/tailbone/templates/mobile/datasync.mako +++ /dev/null @@ -1,9 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">DataSync</%def> - -${h.form(url('datasync.restart'))} -${h.csrf_token(request)} -${h.submit('restart', "Restart DataSync Daemon", id='datasync-restart')} -${h.end_form()} diff --git a/tailbone/templates/mobile/grids/complete.mako b/tailbone/templates/mobile/grids/complete.mako deleted file mode 100644 index ebb58334..00000000 --- a/tailbone/templates/mobile/grids/complete.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8; -*- - -% if grid.filterable: - ${grid.render_filters()|n} -% endif - -${grid.render_grid()|n} diff --git a/tailbone/templates/mobile/grids/filters_simple.mako b/tailbone/templates/mobile/grids/filters_simple.mako deleted file mode 100644 index 1286d99a..00000000 --- a/tailbone/templates/mobile/grids/filters_simple.mako +++ /dev/null @@ -1,15 +0,0 @@ -## -*- coding: utf-8; -*- -<div class="simple-filter"> - ${h.form(request.current_route_url(_query=None), method='get')} - - % for filtr in grid.iter_filters(): - ${h.hidden('{}.verb'.format(filtr.key), value=filtr.verb)} - <fieldset data-role="controlgroup" data-type="horizontal"> - % for value, label in filtr.iter_choices(): - ${h.radio(filtr.key, value=value, label=label, checked=value == filtr.value)} - % endfor - </fieldset> - % endfor - - ${h.end_form()} -</div><!-- simple-filter --> diff --git a/tailbone/templates/mobile/grids/grid.mako b/tailbone/templates/mobile/grids/grid.mako deleted file mode 100644 index b7b029b5..00000000 --- a/tailbone/templates/mobile/grids/grid.mako +++ /dev/null @@ -1,36 +0,0 @@ -## -*- coding: utf-8; -*- - -<ul data-role="listview"> - ${grid.make_webhelpers_grid()} -</ul> - -## <table data-role="table" class="ui-responsive table-stroke"> -## <thead> -## <tr> -## % for column in grid.iter_visible_columns(): -## ${grid.column_header(column)} -## % endfor -## </tr> -## </thead> -## <tbody> -## % for i, row in enumerate(grid.iter_rows(), 1): -## <tr> -## % for column in grid.iter_visible_columns(): -## <td>${grid.render_cell(row, column)}</td> -## % endfor -## </tr> -## % endfor -## </tbody> -## </table> - -% if grid.pageable and grid.pager: - <br /> - <div data-role="controlgroup" data-type="horizontal"> - ${grid.pager.pager('$link_first $link_previous $link_next $link_last', - symbol_first='<< first', symbol_last='last >>', - symbol_previous='< prev', symbol_next='next >', - link_attr={'class': 'ui-btn ui-corner-all'}, - curpage_attr={'class': 'ui-btn ui-corner-all'}, - dotdot_attr={'class': 'ui-btn ui-corner-all'})|n} - </div> -% endif diff --git a/tailbone/templates/mobile/home.mako b/tailbone/templates/mobile/home.mako deleted file mode 100644 index ebb0bda8..00000000 --- a/tailbone/templates/mobile/home.mako +++ /dev/null @@ -1,11 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">Home</%def> - -<%def name="page_title()"></%def> - -<div style="text-align: center;"> - ${h.image(image_url, "{} logo".format(capture(self.app_title)), id='logo', width=300)} - <h3>Welcome to ${self.app_title()}</h3> -</div> diff --git a/tailbone/templates/mobile/keypad.mako b/tailbone/templates/mobile/keypad.mako deleted file mode 100644 index 21b70b58..00000000 --- a/tailbone/templates/mobile/keypad.mako +++ /dev/null @@ -1,39 +0,0 @@ -## -*- coding: utf-8; -*- - -<%def name="keypad(unit_uom, selected_uom, quantity=1)"> - <div class="quantity-keypad-thingy" data-changed="false"> - - <table> - <tbody> - <tr> - <td>${h.link_to("7", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("8", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("9", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - </tr> - <tr> - <td>${h.link_to("4", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("5", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("6", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - </tr> - <tr> - <td>${h.link_to("1", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("2", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("3", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - </tr> - <tr> - <td>${h.link_to("0", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to(".", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - <td>${h.link_to("Del", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td> - </tr> - </tbody> - </table> - - <fieldset data-role="controlgroup" data-type="horizontal"> - <button type="button" class="ui-btn-active keypad-quantity">${h.pretty_quantity(quantity or 1)}</button> - <button type="button" disabled="disabled"> </button> - ${h.radio('keypad-uom', value='CS', checked=selected_uom == 'CS', label="CS")} - ${h.radio('keypad-uom', value=unit_uom, checked=selected_uom == unit_uom, label=unit_uom)} - </fieldset> - - </div> -</%def> diff --git a/tailbone/templates/mobile/login.mako b/tailbone/templates/mobile/login.mako deleted file mode 100644 index 5a5efb9f..00000000 --- a/tailbone/templates/mobile/login.mako +++ /dev/null @@ -1,7 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/mobile/base.mako" /> -<%namespace file="/login.mako" import="login_form" /> - -<%def name="title()">Login</%def> - -${login_form()} diff --git a/tailbone/templates/mobile/master/create.mako b/tailbone/templates/mobile/master/create.mako deleted file mode 100644 index 9bcca732..00000000 --- a/tailbone/templates/mobile/master/create.mako +++ /dev/null @@ -1,8 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">New ${model_title}</%def> - -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> diff --git a/tailbone/templates/mobile/master/create_row.mako b/tailbone/templates/mobile/master/create_row.mako deleted file mode 100644 index 8a6157d2..00000000 --- a/tailbone/templates/mobile/master/create_row.mako +++ /dev/null @@ -1,4 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/create.mako" /> - -${parent.body()} diff --git a/tailbone/templates/mobile/master/edit.mako b/tailbone/templates/mobile/master/edit.mako deleted file mode 100644 index 3c13a8e4..00000000 --- a/tailbone/templates/mobile/master/edit.mako +++ /dev/null @@ -1,10 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">${index_title} » ${instance_title} » Edit</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Edit</%def> - -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> diff --git a/tailbone/templates/mobile/master/edit_row.mako b/tailbone/templates/mobile/master/edit_row.mako deleted file mode 100644 index 31b0156d..00000000 --- a/tailbone/templates/mobile/master/edit_row.mako +++ /dev/null @@ -1,16 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/edit.mako" /> - -<%def name="title()">${index_title} » ${parent_title} » ${instance_title} » Edit</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(parent_title, parent_url)} » ${h.link_to(instance_title, instance_url)} » Edit</%def> - -<%def name="buttons()"> - <br /> - ${h.submit('create', form.update_label)} - <a href="${form.cancel_url}" class="ui-btn">Cancel</a> -</%def> - -<div class="form-wrapper"> - ${form.render(buttons=capture(self.buttons))|n} -</div><!-- form-wrapper --> diff --git a/tailbone/templates/mobile/master/index.mako b/tailbone/templates/mobile/master/index.mako deleted file mode 100644 index b4431b69..00000000 --- a/tailbone/templates/mobile/master/index.mako +++ /dev/null @@ -1,16 +0,0 @@ -## -*- coding: utf-8; -*- -## ############################################################################## -## -## Default master 'index' template for mobile. Features a somewhat abbreviated -## data table and (hopefully) exposes a way to filter and sort the data, etc. -## -## ############################################################################## -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">${index_title}</%def> - -% if master.mobile_creatable and request.has_perm('{}.create'.format(permission_prefix)): - ${h.link_to("New {}".format(model_title), url('mobile.{}.create'.format(route_prefix)), class_='ui-btn ui-corner-all')} -% endif - -${grid.render_complete()|n} diff --git a/tailbone/templates/mobile/master/view.mako b/tailbone/templates/mobile/master/view.mako deleted file mode 100644 index 624cd929..00000000 --- a/tailbone/templates/mobile/master/view.mako +++ /dev/null @@ -1,14 +0,0 @@ -## -*- coding: utf-8; -*- -## ############################################################################## -## -## Default master 'view' template for mobile. Features a basic field list, and -## links to edit/delete the object when appropriate. -## -## ############################################################################## -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">${index_title} » ${instance_title}</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${instance_title}</%def> - -${form.render()|n} diff --git a/tailbone/templates/mobile/master/view_row.mako b/tailbone/templates/mobile/master/view_row.mako deleted file mode 100644 index 109ad415..00000000 --- a/tailbone/templates/mobile/master/view_row.mako +++ /dev/null @@ -1,12 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view.mako" /> - -<%def name="title()">${index_title} » ${parent_title} » ${instance_title}</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » ${h.link_to(parent_title, parent_url)} » ${instance_title}</%def> - -${parent.body()} - -% if master.mobile_rows_editable and instance_editable and request.has_perm('{}.edit_row'.format(permission_prefix)): - ${h.link_to("Edit", url('mobile.{}.edit'.format(row_route_prefix), uuid=instance.uuid), class_='ui-btn')} -% endif diff --git a/tailbone/templates/mobile/ordering/create.mako b/tailbone/templates/mobile/ordering/create.mako deleted file mode 100644 index 68f11737..00000000 --- a/tailbone/templates/mobile/ordering/create.mako +++ /dev/null @@ -1,22 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">${index_title} » New Batch</%def> - -<%def name="page_title()">${h.link_to(index_title, index_url)} » New Batch</%def> - -${h.form(request.current_route_url(), class_='ui-filterable', name='new-purchasing-batch')} -${h.csrf_token(request)} - -<div class="field-wrapper vendor"> - <div class="field autocomplete" data-url="${url('vendors.autocomplete')}"> - ${h.hidden('vendor')} - ${h.text('new-purchasing-batch-vendor-text', placeholder="Vendor name", autocomplete='off', data_type='search')} - <ul data-role="listview" data-inset="true" data-filter="true" data-input="#new-purchasing-batch-vendor-text"></ul> - <button type="button" style="display: none;">Change Vendor</button> - </div> -</div> - -<br /> -${h.submit('submit', "Make Batch")} -${h.end_form()} diff --git a/tailbone/templates/mobile/ordering/create_row.mako b/tailbone/templates/mobile/ordering/create_row.mako deleted file mode 100644 index d31814f8..00000000 --- a/tailbone/templates/mobile/ordering/create_row.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/create_row.mako" /> - -<%def name="title()">${h.link_to(index_title, index_url)} » ${h.link_to(instance_title, instance_url)} » Add Item</%def> - -${parent.body()} diff --git a/tailbone/templates/mobile/receiving/create.mako b/tailbone/templates/mobile/receiving/create.mako deleted file mode 100644 index b8c3d3e1..00000000 --- a/tailbone/templates/mobile/receiving/create.mako +++ /dev/null @@ -1,50 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/base.mako" /> - -<%def name="title()">Receiving » New Batch</%def> - -<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » New Batch</%def> - -${h.form(request.current_route_url(), class_='ui-filterable', name='new-purchasing-batch')} -${h.csrf_token(request)} - -% if vendor is Undefined: - - <div class="field-wrapper vendor"> - <div class="field autocomplete" data-url="${url('vendors.autocomplete')}"> - ${h.hidden('vendor')} - ${h.text('new-purchasing-batch-vendor-text', placeholder="Vendor name", autocomplete='off', **{'data-type': 'search'})} - <ul data-role="listview" data-inset="true" data-filter="true" data-input="#new-purchasing-batch-vendor-text"></ul> - <button type="button" style="display: none;">Change Vendor</button> - </div> - </div> - - <br /> - ${h.submit('submit', "Find purchase orders")} - ## <button type="button">New receiving from scratch</button> - -% else: ## vendor is known - - <div class="field-wrapper vendor"> - <div class="field"> - ${h.hidden('vendor', value=vendor.uuid)} - ${vendor} - </div> - </div> - - % if purchases: - ${h.hidden('purchase')} - <ul data-role="listview" data-inset="true"> - % for uuid, purchase in purchases: - <li data-uuid="${uuid}">${h.link_to(purchase, '#')}</li> - % endfor - </ul> - % else: - <p>(no eligible purchases found)</p> - % endif - - ## ${h.link_to("Receive from scratch for {}".format(vendor), '#', class_='ui-btn ui-corner-all')} - -% endif - -${h.end_form()} diff --git a/tailbone/templates/mobile/receiving/view.mako b/tailbone/templates/mobile/receiving/view.mako deleted file mode 100644 index 0df7b50c..00000000 --- a/tailbone/templates/mobile/receiving/view.mako +++ /dev/null @@ -1,24 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view.mako" /> - -<%def name="title()">Receiving » ${instance.id_str}</%def> - -<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${instance.id_str}</%def> - -${form.render()|n} -<br /> - -% if not instance.executed and not instance.complete: - ${h.text('upc-search', class_='receiving-upc-search', placeholder="Enter UPC", autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.receiving.lookup', uuid=batch.uuid)})} - <br /> -% endif - -${grid.render_complete()|n} - -% if not instance.executed and not instance.complete: - <br /><br /> - ${h.form(request.route_url('mobile.receiving.mark_complete', uuid=instance.uuid))} - ${h.csrf_token(request)} - ${h.hidden('mark-complete', value='true')} - <button type="submit">Mark Batch as Complete</button> -% endif diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako deleted file mode 100644 index 4bc1fe34..00000000 --- a/tailbone/templates/mobile/receiving/view_row.mako +++ /dev/null @@ -1,102 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/mobile/master/view_row.mako" /> -<%namespace file="/mobile/keypad.mako" import="keypad" /> - -<%def name="title()">Receiving » ${instance.batch.id_str} » ${row.upc.pretty()}</%def> - -<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(instance.batch.id_str, url('mobile.receiving.view', uuid=instance.batch_uuid))} » ${row.upc.pretty()}</%def> - -<% - unit_uom = 'LB' if row.product and row.product.weighed else 'EA' - - uom = 'CS' - if row.units_ordered and not row.cases_ordered: - uom = 'EA' -%> - - -<div class="ui-grid-a"> - <div class="ui-block-a"> - % if instance.product: - <h3>${instance.brand_name or ""}</h3> - <h3>${instance.description} ${instance.size}</h3> - <h3>${h.pretty_quantity(row.case_quantity)} ${unit_uom} per CS</h3> - % else: - <h3>${instance.description}</h3> - % endif - </div> - <div class="ui-block-b"> - ${h.image(product_image_url, "product image")} - </div> -</div> - -<table> - <tbody> - <tr> - <td>ordered</td> - <td>${h.pretty_quantity(row.cases_ordered or 0)} / ${h.pretty_quantity(row.units_ordered or 0)}</td> - </tr> - <tr> - <td>received</td> - <td>${h.pretty_quantity(row.cases_received or 0)} / ${h.pretty_quantity(row.units_received or 0)}</td> - </tr> - <tr> - <td>damaged</td> - <td>${h.pretty_quantity(row.cases_damaged or 0)} / ${h.pretty_quantity(row.units_damaged or 0)}</td> - </tr> - <tr> - <td>expired</td> - <td>${h.pretty_quantity(row.cases_expired or 0)} / ${h.pretty_quantity(row.units_expired or 0)}</td> - </tr> - </tbody> -</table> - -% if request.session.peek_flash('receiving-warning'): - % for error in request.session.pop_flash('receiving-warning'): - <div class="receiving-warning">${error}</div> - % endfor -% endif - -% if not instance.batch.executed and not instance.batch.complete: - - ${h.form(request.current_route_url(), class_='receiving-update')} - ${h.csrf_token(request)} - ${h.hidden('row', value=row.uuid)} - ${h.hidden('cases')} - ${h.hidden('units')} - - ${keypad(unit_uom, uom)} - - <table> - <tbody> - <tr> - <td> - <fieldset data-role="controlgroup" data-type="horizontal" class="receiving-mode"> - ${h.radio('mode', value='received', label="received", checked=True)} - ${h.radio('mode', value='damaged', label="damaged")} - ${h.radio('mode', value='expired', label="expired")} - </fieldset> - </td> - </tr> - <tr id="expiration-row" style="display: none;"> - <td> - <div style="padding:10px 20px;"> - <label for="expiration_date">Expiration Date</label> - <input name="expiration_date" type="date" value="" placeholder="YYYY-MM-DD" /> - </div> - </td> - </tr> - <tr> - <td> - <fieldset data-role="controlgroup" data-type="horizontal" class="receiving-actions"> - <button type="button" data-action="add" class="ui-btn-inline ui-corner-all">Add</button> - <button type="button" data-action="subtract" class="ui-btn-inline ui-corner-all">Subtract</button> - ## <button type="button" data-action="clear" class="ui-btn-inline ui-corner-all ui-state-disabled">Clear</button> - </fieldset> - </td> - </tr> - </tbody> - </table> - - ${h.end_form()} -% endif 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 4a8c5d7d..8f2d5e27 100644 --- a/tailbone/templates/ordering/create.mako +++ b/tailbone/templates/ordering/create.mako @@ -1,81 +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(${form.fieldset.model.mode or enum.PURCHASE_BATCH_MODE_ORDERING}); - show_mode(${enum.PURCHASE_BATCH_MODE_ORDERING}); - - }); - - </script> -</%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 4d12e643..34a6085f 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -1,27 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - $(function() { - - $('#order-form').click(function() { - % if vendor_cost_count > vendor_cost_threshold: - if (! confirm("This vendor has ${'{:,d}'.format(vendor_cost_count)} cost records.\n\n" + - "It is not recommended to use Order Form mode for such a large catalog.\n\n" + - "Are you sure you wish to do it anyway?")) { - return; - } - % endif - $(this).button('disable').button('option', 'label', "Working, please wait..."); - location.href = '${url('ordering.order_form', uuid=batch.uuid)}'; - }); - - }); - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} ${h.stylesheet_link(request.static_url('tailbone:static/css/purchases.css'))} @@ -34,10 +13,406 @@ % endif </%def> -<%def name="leading_buttons()"> - % if not batch.complete and not batch.executed and request.has_perm('ordering.order_form'): - <button type="button" id="order-form">Edit as Worksheet</button> +<%def name="render_row_grid_tools()"> + ${parent.render_row_grid_tools()} + % if not batch.executed and not batch.complete and master.has_perm('edit_row'): + <ordering-scanner numeric-only> + </ordering-scanner> % endif </%def> -${parent.body()} +<%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="play" + @click="startScanning()"> + Start Scanning + </b-button> + <b-modal :active.sync="showScanningDialog" + :can-cancel="false"> + <div class="card"> + <div class="card-content"> + <section style="min-height: 400px;"> + <div class="columns"> + + <div class="column"> + <b-field grouped> + + <numeric-input v-if="numericOnly" + v-model="itemEntry" + allow-enter + placeholder="Enter UPC" + icon-pack="fas" + icon="fas fa-search" + ref="itemEntryInput" + :disabled="currentRow" + @keydown.native="itemEntryKeydown"> + </numeric-input> + + <b-input v-if="!numericOnly" + v-model="itemEntry" + placeholder="Enter UPC" + icon-pack="fas" + icon="fas fa-search" + ref="itemEntryInput" + :disabled="currentRow"> + </b-input> + + <b-button @click="fetchEntry()" + :disabled="currentRow"> + Fetch + </b-button> + + </b-field> + + <div v-if="currentRow"> + <b-field grouped> + + <b-field label="${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]}" horizontal> + <numeric-input v-model="currentRow.cases_ordered" + ref="casesInput" + @keydown.native="casesKeydown" + style="width: 60px; margin-right: 1rem;"> + </numeric-input> + </b-field> + + <b-field :label="currentRow.unit_of_measure_display" horizontal> + <numeric-input v-model="currentRow.units_ordered" + ref="unitsInput" + @keydown.native="unitsKeydown" + style="width: 60px;"> + </numeric-input> + </b-field> + + </b-field> + + <p class="block has-text-weight-bold"> + 1 ${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]} + = {{ currentRow.case_quantity || '??' }} + {{ currentRow.unit_of_measure_display }} + </p> + + <p class="block has-text-weight-bold"> + {{ currentRow.po_case_cost_display || '$?.??' }} + per ${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]}; + {{ currentRow.po_unit_cost_display || '$?.??' }} + per {{ currentRow.unit_of_measure_display }} + </p> + + <p class="block has-text-weight-bold"> + Total is + {{ totalCostDisplay }} + </p> + + <div class="buttons"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="save" + @click="saveCurrentRow()"> + Save + </b-button> + <b-button @click="cancelCurrentRow()"> + Cancel + </b-button> + </div> + </div> + + </div> + + <div class="column is-three-fifths"> + <div v-if="currentRow"> + + <b-field label="UPC" horizontal> + {{ currentRow.upc_display }} + </b-field> + + <b-field label="Brand" horizontal> + {{ currentRow.brand_name }} + </b-field> + + <b-field label="Description" horizontal> + {{ currentRow.description }} + </b-field> + + <b-field label="Size" horizontal> + {{ currentRow.size }} + </b-field> + + <b-field label="Reg. Price" horizontal> + {{ currentRow.product_price_display }} + </b-field> + + <div class="buttons"> + <img :src="currentRow.image_url"></img> + <b-button v-if="currentRow.product_url" + type="is-primary" + tag="a" :href="currentRow.product_url" + target="_blank"> + View Full Product + </b-button> + </div> + </div> + </div> + + </div> <!-- columns --> + </section> + + <div class="level"> + <div class="level-left"> + </div> + <div class="level-right"> + <div class="level-item buttons"> + <once-button type="is-primary" + @click="stopScanning()" + text="Stop Scanning" + icon-left="stop" + :disabled="currentRow" + :title="currentRow ? 'Please save or cancel first' : null"> + </once-button> + </div> + </div> + </div> + + </div> <!-- card-content --> + </div> + </b-modal> + </div> + </script> + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if not batch.executed and not batch.complete and master.has_perm('edit_row'): + <script> + + let OrderingScanner = { + template: '#ordering-scanner-template', + props: { + numericOnly: Boolean, + }, + data() { + return { + showScanningDialog: false, + itemEntry: null, + fetching: false, + currentRow: null, + saving: false, + + ## TODO: should find a better way to handle CSRF token + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, + } + }, + computed: { + + totalUnits() { + let cases = parseFloat(this.currentRow.cases_ordered || 0) + let units = parseFloat(this.currentRow.units_ordered || 0) + if (cases) { + units += cases * (this.currentRow.case_quantity || 1) + } + return units + }, + + totalUnitsDisplay() { + let cases = parseFloat(this.currentRow.cases_ordered || 0) + let units = parseFloat(this.currentRow.units_ordered || 0) + let casesTotal = "" + if (cases) { + casesTotal = cases.toString() + " ${enum.UNIT_OF_MEASURE[enum.UNIT_OF_MEASURE_CASE]}" + } + let unitsTotal = "" + if (units) { + unitsTotal = units.toString() + " " + this.currentRow.unit_of_measure_display + } + if (casesTotal.length && unitsTotal.length) { + return casesTotal + " + " + unitsTotal + } else if (casesTotal.length) { + return casesTotal + } else if (unitsTotal.length) { + return unitsTotal + } + return "??" + }, + + totalCost() { + if (this.currentRow.po_case_cost === null + && this.currentRow.po_unit_cost === null) { + return null + } + let cases = parseFloat(this.currentRow.cases_ordered || 0) + let units = parseFloat(this.currentRow.units_ordered || 0) + let total = cases * this.currentRow.po_case_cost + total += units * this.currentRow.po_unit_cost + return total + }, + + totalCostDisplay() { + if (this.totalCost === null) { + return '$?.??' + } + return '$' + this.totalCost.toFixed(2) + }, + }, + methods: { + + startScanning() { + this.showScanningDialog = true + this.$nextTick(() => { + this.$refs.itemEntryInput.focus() + }) + }, + + itemEntryKeydown(event) { + if (event.which == 13) { + this.fetchEntry() + } + }, + + fetchEntry() { + if (this.fetching) { + return + } + if (!this.itemEntry) { + return + } + + this.fetching = true + + let url = '${url('{}.scanning_entry'.format(route_prefix), uuid=batch.uuid)}' + + let params = { + entry: this.itemEntry, + } + + 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.error) { + this.$buefy.toast.open({ + message: "Fetch failed: " + data.error, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } else { + this.currentRow = data.row + this.$nextTick(() => { + this.$refs.casesInput.focus() + }) + } + this.fetching = false + }, response => { + this.$buefy.toast.open({ + message: "Fetch failed: (unknown error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + this.fetching = false + }) + }, + + casesKeydown(event) { + if (event.which == 13) { + this.$refs.unitsInput.focus() + } else if (event.which == 27) { + this.cancelCurrentRow() + } + }, + + unitsKeydown(event) { + if (event.which == 13) { + this.saveCurrentRow() + } else if (event.which == 27) { + this.cancelCurrentRow() + } + }, + + saveCurrentRow() { + if (this.saving) { + return + } + + this.saving = true + + let url = '${url('{}.scanning_update'.format(route_prefix), uuid=batch.uuid)}' + + let params = { + row_uuid: this.currentRow.uuid, + cases_ordered: this.currentRow.cases_ordered, + units_ordered: this.currentRow.units_ordered, + } + + 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.error) { + this.$buefy.toast.open({ + message: "Save failed: " + data.error, + type: 'is-danger', + duration: 4000, // 4 seconds + }) + } else { + this.$buefy.toast.open({ + message: "Item was saved", + type: 'is-success', + }) + this.itemEntry = null + this.currentRow = null + this.$nextTick(() => { + this.$refs.itemEntryInput.focus() + }) + } + this.saving = false + }, response => { + this.$buefy.toast.open({ + message: "Save failed: (unknown error)", + type: 'is-danger', + duration: 4000, // 4 seconds + }) + this.saving = false + }) + + }, + + cancelCurrentRow() { + this.itemEntry = null + this.currentRow = null + this.$buefy.toast.open({ + message: "Edit was cancelled", + type: 'is-warning', + }) + this.$nextTick(() => { + this.$refs.itemEntryInput.focus() + }) + }, + + stopScanning() { + location.reload() + }, + } + } + + </script> + % endif +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + % if not batch.executed and not batch.complete and master.has_perm('edit_row'): + <script> + Vue.component('ordering-scanner', OrderingScanner) + </script> + % endif +</%def> diff --git a/tailbone/templates/ordering/order_form.mako b/tailbone/templates/ordering/worksheet.mako similarity index 56% rename from tailbone/templates/ordering/order_form.mako rename to tailbone/templates/ordering/worksheet.mako index bca0ff6f..eb2077e7 100644 --- a/tailbone/templates/ordering/order_form.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -1,57 +1,8 @@ ## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> +<%inherit file="/batch/worksheet.mako" /> <%def name="title()">Ordering Worksheet</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - ${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 { - 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); - $('.po-total .field').html(data.batch_po_total); - } - submitting = false; - }); - } - } - return false; - }); - - }); - </script> -</%def> - <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> @@ -106,70 +57,10 @@ </style> </%def> - <%def name="context_menu_items()"> <li>${h.link_to("Back to {}".format(model_title), url('ordering.view', uuid=batch.uuid))}</li> </%def> - -############################## -## page body -############################## - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<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> - <div class="field">$${'{:0,.2f}'.format(batch.po_total or 0)}</div> - </div> - -</div><!-- form-wrapper --> - -${self.order_form_grid()} - -${h.form(url('ordering.order_form_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()} - - -############################## -## methods -############################## - <%def name="extra_vendor_fields()"></%def> <%def name="extra_count()">0</%def> @@ -224,12 +115,12 @@ ${h.end_form()} % endfor % if not ignore_cases: <th> - ${batch.date_ordered.strftime('%m/%d')}<br /> + ${order_date.strftime('%m/%d')}<br /> Cases </th> % endif <th> - ${batch.date_ordered.strftime('%m/%d')}<br /> + ${order_date.strftime('%m/%d')}<br /> Units </th> <th>PO Total</th> @@ -238,7 +129,8 @@ ${h.end_form()} </thead> <tbody> % for i, cost in enumerate(subdepartment._order_costs, 1): - <tr data-uuid="${cost.product_uuid}" class="${'even' if i % 2 == 0 else 'odd'}"> + <tr data-uuid="${cost.product_uuid}" class="${'even' if i % 2 == 0 else 'odd'}" + :class="{active: activeUUID == '${cost.uuid}'}"> ${self.order_form_row(cost)} % for data in history: <td class="scratch_pad"> @@ -264,13 +156,21 @@ ${h.end_form()} % endfor % if not ignore_cases: <td class="current-order"> - ${h.text('cases_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.cases_ordered or 0) if cost._batchrow else None)} + <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> </td> % endif <td class="current-order"> - ${h.text('units_ordered_{}'.format(cost.uuid), value=int(cost._batchrow.units_ordered or 0) if cost._batchrow else None)} + <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">${'${:0,.2f}'.format(cost._batchrow.po_total or 0) if cost._batchrow else ''}</td> + <td class="po-total">{{ worksheet.cost_${cost.uuid}_total_display }}</td> ${self.extra_td(cost)} </tr> % endfor @@ -288,5 +188,117 @@ ${h.end_form()} <td class="case-qty">${h.pretty_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> - <td class="unit-cost">$${'{:0.2f}'.format(cost.unit_cost)}</td> + <td class="unit-cost"> + % if cost.unit_cost is not None: + $${'{:0.2f}'.format(cost.unit_cost)} + % endif + </td> +</%def> + +<%def name="page_content()"> + <ordering-worksheet></ordering-worksheet> +</%def> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + <script type="text/x-template" id="ordering-worksheet-template"> + <div> + <div class="form-wrapper"> + <div class="form"> + + <b-field horizontal label="Vendor"> + ${h.link_to(vendor, url('vendors.view', uuid=vendor.uuid))} + </b-field> + + <b-field horizontal label="Vendor Email"> + <span>${vendor.email or ''}</span> + </b-field> + + <b-field horizontal label="Vendor Fax"> + <span>${vendor.fax_number or ''}</span> + </b-field> + + <b-field horizontal label="Vendor Contact"> + <span>${vendor.contact or ''}</span> + </b-field> + + <b-field horizontal label="Vendor Phone"> + <span>${vendor.phone or ''}</span> + </b-field> + + ${self.extra_vendor_fields()} + + <b-field horizontal label="PO Total"> + <span>{{ poTotalDisplay }}</span> + </b-field> + + </div> <!-- form --> + </div><!-- form-wrapper --> + + ${self.order_form_grid()} + </div> + </script> + <script> + + const OrderingWorksheet = { + template: '#ordering-worksheet-template', + data() { + return { + worksheet: ${json.dumps(worksheet_data)|n}, + activeUUID: null, + poTotalDisplay: "$${'{:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0)}", + submitting: false, + + ## TODO: should find a better way to handle CSRF token + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, + } + }, + methods: { + + inputKeydown(event, cost_uuid, product_uuid) { + if (event.which == 13) { + if (!this.submitting) { + this.submitting = true + + let url = '${url('ordering.worksheet_update', uuid=batch.uuid)}' + + let params = { + product_uuid: product_uuid, + % if not ignore_cases: + cases_ordered: this.worksheet['cost_' + cost_uuid + '_cases'], + % endif + units_ordered: this.worksheet['cost_' + cost_uuid + '_units'], + } + + let headers = { + ## TODO: should find a better way to handle CSRF token + 'X-CSRF-TOKEN': this.csrftoken, + } + + ## TODO: should find a better way to handle CSRF token + this.$http.post(url, params, {headers: headers}).then(response => { + if (response.data.error) { + alert(response.data.error) + } else { + this.worksheet['cost_' + cost_uuid + '_cases'] = response.data.row_cases_ordered + this.worksheet['cost_' + cost_uuid + '_units'] = response.data.row_units_ordered + this.worksheet['cost_' + cost_uuid + '_total_display'] = response.data.row_po_total_display + this.poTotalDisplay = response.data.batch_po_total_display + } + this.submitting = false + }) + } + } + }, + }, + } + + </script> +</%def> + +<%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 new file mode 100644 index 00000000..43b0a266 --- /dev/null +++ b/tailbone/templates/page.mako @@ -0,0 +1,106 @@ +## -*- coding: utf-8; -*- +<%inherit file="/base.mako" /> + +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${self.render_vue_template_this_page()} +</%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;"> + + <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> + +## 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="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + ## DEPRECATED; called for back-compat + ${self.declare_this_page_vars()} + ${self.modify_this_page_vars()} +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + + ## DEPRECATED; called for back-compat + ${self.make_this_page_component()} +</%def> + +<%def name="make_this_page_component()"> + ${self.finalize_this_page_vars()} + <script> + ThisPage.data = function() { return ThisPageData } + Vue.component('this-page', ThisPage) + <% request.register_component('this-page', 'ThisPage') %> + </script> +</%def> + +############################## +## DEPRECATED +############################## + +<%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 new file mode 100644 index 00000000..cd6fddf1 --- /dev/null +++ b/tailbone/templates/people/index.mako @@ -0,0 +1,102 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="grid_tools()"> + + % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): + <b-button @click="showMergeRequest()" + icon-pack="fas" + icon-left="object-ungroup" + :disabled="checkedRows.length != 2"> + Request Merge + </b-button> + <b-modal has-modal-card + :active.sync="mergeRequestShowDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Request Merge of 2 People</p> + </header> + + <section class="modal-card-body"> + <b-table :data="mergeRequestRows" + striped hoverable> + <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> + + <footer class="modal-card-foot"> + <b-button @click="mergeRequestShowDialog = false"> + Cancel + </b-button> + ${h.form(url('{}.request_merge'.format(route_prefix)), **{'@submit': 'submitMergeRequest'})} + ${h.csrf_token(request)} + ${h.hidden('removing_uuid', **{':value': 'mergeRequestRemovingUUID'})} + ${h.hidden('keeping_uuid', **{':value': 'mergeRequestKeepingUUID'})} + <b-button type="is-primary" + native-type="submit" + :disabled="mergeRequestSubmitting"> + {{ mergeRequestSubmitText }} + </b-button> + ${h.end_form()} + </footer> + </div> + </b-modal> + % endif + + ${parent.grid_tools()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): + + ${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.vue_component}.computed.mergeRequestRemovingUUID = function() { + if (this.mergeRequestRows.length) { + return this.mergeRequestRows[0].uuid + } + return null + } + + ${grid.vue_component}.computed.mergeRequestKeepingUUID = function() { + if (this.mergeRequestRows.length) { + return this.mergeRequestRows[1].uuid + } + return null + } + + ${grid.vue_component}.methods.showMergeRequest = function() { + this.mergeRequestRows = this.checkedRows + this.mergeRequestShowDialog = true + } + + ${grid.vue_component}.methods.submitMergeRequest = function() { + this.mergeRequestSubmitting = true + this.mergeRequestSubmitText = "Working, please wait..." + } + + % endif + + </script> +</%def> diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako new file mode 100644 index 00000000..e2db1476 --- /dev/null +++ b/tailbone/templates/people/merge-requests/view.mako @@ -0,0 +1,36 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + % if not instance.merged and request.has_perm('people.merge'): + ${h.form(url('people.merge'), **{'@submit': 'submitMergeForm'})} + ${h.csrf_token(request)} + ${h.hidden('uuids', value=','.join([instance.removing_uuid, instance.keeping_uuid]))} + <b-button type="is-primary" + native-type="submit" + :disabled="mergeFormSubmitting" + icon-pack="fas" + icon-left="object-ungroup"> + {{ mergeFormButtonText }} + </b-button> + ${h.end_form()} + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if not instance.merged and request.has_perm('people.merge'): + <script> + + ThisPageData.mergeFormButtonText = "Perform Merge" + ThisPageData.mergeFormSubmitting = false + + ThisPage.methods.submitMergeForm = function() { + this.mergeFormButtonText = "Working, please wait..." + this.mergeFormSubmitting = true + } + + </script> + % endif +</%def> diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako index 72079db8..15c669fa 100644 --- a/tailbone/templates/people/view.mako +++ b/tailbone/templates/people/view.mako @@ -1,27 +1,41 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%namespace file="/util.mako" import="view_profiles_helper" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} +<%def name="page_content()"> + ${parent.page_content()} % if not instance.users and request.has_perm('users.create'): - <script type="text/javascript"> - $(function() { - $('#make-user').click(function() { - if (confirm("Really make a user account for this person?")) { - disable_button(this); - $('form[name="make-user-form"]').submit(); - } - }); - }); - </script> + ${h.form(url('people.make_user'), ref='makeUserForm')} + ${h.csrf_token(request)} + ${h.hidden('person_uuid', value=instance.uuid)} + ${h.end_form()} % endif </%def> -${parent.body()} +<%def name="object_helpers()"> + ${parent.object_helpers()} + ${view_profiles_helper([instance])} +</%def> -% if not instance.users and request.has_perm('users.create'): - ${h.form(url('people.make_user'), name='make-user-form')} - ${h.csrf_token(request)} - ${h.hidden('person_uuid', value=instance.uuid)} - ${h.end_form()} -% endif +<%def name="render_form()"> + <div class="form"> + <${form.vue_tagname} v-on:make-user="makeUser"></${form.vue_tagname}> + </div> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${form.vue_component}.methods.clickMakeUser = function(event) { + this.$emit('make-user') + } + + ThisPage.methods.makeUser = function(event) { + if (confirm("Really make a user account for this person?")) { + this.$refs.makeUserForm.submit() + } + } + + </script> +</%def> diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako new file mode 100644 index 00000000..6ca5a84c --- /dev/null +++ b/tailbone/templates/people/view_profile.mako @@ -0,0 +1,3256 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + .card.personal { + margin-bottom: 1rem; + } + .field.is-horizontal .field-label .label { + white-space: nowrap; + min-width: 10rem; + } + </style> +</%def> + +<%def name="content_title()"> + ${dynamic_content_title or str(instance)} +</%def> + +<%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 style="flex-grow: 1; margin-right: 1rem;"> + ${self.render_personal_tab_cards()} + </div> + + <div> + % if request.has_perm('people.view'): + <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> + </script> +</%def> + +<%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> + <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> + <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 + this.editNameMiddle = this.person.middle_name + this.editNameLast = this.person.last_name + this.editNameShowDialog = true + }, + + 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, + } + + 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 + }) + }, + + 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 + }, + + 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, + } + + 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 + }) + }, + + addPhoneInit() { + this.editPhoneInit({ + uuid: null, + type: 'Home', + number: null, + preferred: false, + }) + }, + + 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() + }) + }, + + 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 + }, + + % endif + + % 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 + + }, + 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 + }) + }, + + % endif + + % if request.has_perm('people_profile.toggle_employee'): + + startEmployeeInit() { + this.startEmployeeID = this.employee.id || null + this.startEmployeeStartDate = null + this.startEmployeeShowDialog = true + }, + + 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 + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.startEmployeeShowDialog = false + this.refreshTab() + this.startEmployeeSaving = false + }, response => { + this.startEmployeeSaving = false + }) + }, + + stopEmployeeInit() { + this.stopEmployeeEndDate = null + this.stopEmployeeRevokeAccess = false + this.stopEmployeeShowDialog = true + }, + + 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, + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.stopEmployeeShowDialog = false + this.stopEmployeeSaving = false + this.refreshTab() + }, response => { + this.stopEmployeeSaving = false + }) + }, + + % 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 + let params = { + username: this.createUserUsername, + active: this.createUserActive, + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.createUserSaving = false + this.createUserShowDialog = false + this.refreshTab() + }, response => { + this.createUserSaving = false + }) + }, + + % endif + }, + } + + </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 + + 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/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 f257901b..ddc44e3d 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -1,67 +1,246 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> <%def name="title()">Find ${model_title_plural} by Permission</%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} +<%def name="page_content()"> + <br /> + <find-principals :permission-groups="permissionGroups" + :sorted-groups="sortedGroups"> + </find-principals> +</%def> + +<%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> + + ${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> + ${h.end_form()} + + % if principals is not None: + <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> + </script> <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'); + const FindPrincipals = { + template: '#find-principals-template', + props: { + permissionGroups: Object, + sortedGroups: Array + }, + data() { + return { + groupPermissions: ${json.dumps(perms_data.get(selected_group, {}).get('permissions', []))|n}, + permissionGroupTerm: '', + permissionTerm: '', + selectedGroup: ${json.dumps(selected_group)|n}, + selectedPermission: ${json.dumps(selected_permission)|n}, + selectedPermissionLabel: ${json.dumps(selected_permission_label or '')|n}, + formSubmitting: false, + principalsData: ${json.dumps(principals_data)|n}, } - }); + }, - $('#permission').selectmenu(); + computed: { - $('#find-by-perm-form').submit(function() { - $('.grid').remove(); - $(this).find('#submit').button('disable').button('option', 'label', "Searching, please wait..."); - }); + 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 + }, + + 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> -${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 +<%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 bb6307dd..db029e5a 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -1,66 +1,117 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> -<%def name="title()">Products: Create Batch</%def> +<%def name="title()">Create Batch</%def> <%def name="context_menu_items()"> <li>${h.link_to("Back to Products", url('products'))}</li> </%def> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - $(function() { - - $('#batch_type').selectmenu({ - change: function(event, ui) { - $('.params-wrapper').hide(); - $('.params-wrapper.' + ui.item.value).show(); - } - }); - - $('.params-wrapper.' + $('#batch_type').val()).show(); - - $('#make-batch').click(function() { - $(this).button('disable').button('option', 'label', "Working, please wait..."); - $(this).parents('form:first').submit(); - }); - - }); - </script> +<%def name="render_deform_field(form, field)"> + <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="extra_styles()"> - ${parent.extra_styles()} - <style type="text/css"> - .params-wrapper { - display: none; - } - </style> -</%def> - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<div class="form"> - ${h.form(request.current_route_url())} +<%def name="render_form_innards()"> + ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.vue_component)})} ${h.csrf_token(request)} - ${self.wtfield(form, 'batch_type')} + <section> + ${render_deform_field(form, dform['batch_type'])} + ${render_deform_field(form, dform['description'])} + ${render_deform_field(form, dform['notes'])} - % for key, pform in params_forms.items(): - <div class="params-wrapper ${key}"> - % for name in pform._fields: - ${self.wtfield(pform, name)} - % endfor - </div> - % endfor + % 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"> - <button type="button" id="make-batch">Create Batch</button> - ${h.link_to("Cancel", url('products'), class_='button')} + <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()} -</div> +</%def> + +<%def name="render_form_template()"> + <script type="text/x-template" id="${form.vue_tagname}-template"> + ${self.render_form_innards()} + </script> +</%def> + +<%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.mako) + + 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.vue_component}() { + this.${form.vue_component}Submitting = true + this.${form.vue_component}ButtonText = "Working, please wait..." + } + % endif + } + } + + let ${form.vue_component}Data = { + + ## 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.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 + ## (this of course was *not* copied from normal deform template!) + % for key, pform in params_forms.items(): + <% pdform = pform.make_deform_form() %> + % for field in pform.fields: + % if field in pdform: + <% field = pdform[field] %> + field_model_${field.name}: ${pform.get_vuejs_model_value(field)|n}, + % endif + % endfor + % endfor + } + + </script> +</%def> 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 4be1a675..66ca3128 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -1,93 +1,46 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%namespace file="/forms/lib.mako" import="render_field_readonly" /> <%def name="extra_styles()"> ${parent.extra_styles()} <style type="text/css"> - #product-main { + nav.item-panel { + min-width: 600px; + } + #main-product-panel { + margin-right: 2em; margin-top: 1em; - width: 80%; } - #product-image { - float: left; - } - .panel-wrapper { - float: left; - margin-right: 15px; - width: 40%; + #pricing-panel .field-wrapper .field { + white-space: nowrap; } </style> </%def> -############################## -## page body -############################## - -<div class="form-wrapper"> - <ul class="context-menu"> - ${self.context_menu_items()} - </ul> - - <div class="panel" id="product-main"> - <h2>Product</h2> - <div class="panel-body"> - <div style="clear: none; float: left;"> - ${self.render_main_fields(form)} - </div> - % if image_url: - ${h.image(image_url, "Product Image", id='product-image', width=150, height=150)} - % endif - </div> - </div> - - <div class="panel-wrapper"> <!-- left column --> - - ${self.left_column()} - - </div> <!-- left column --> - - <div class="panel-wrapper"> <!-- right column --> - - ${self.right_column()} - - </div> <!-- right column --> - - % if buttons: - ${buttons|n} - % endif -</div> - -############################## -## rendering methods -############################## - <%def name="render_main_fields(form)"> - ${render_field_readonly(form.fieldset.upc)} - ${render_field_readonly(form.fieldset.brand)} - ${render_field_readonly(form.fieldset.description)} - ${render_field_readonly(form.fieldset.size)} - ${render_field_readonly(form.fieldset.unit_size)} - ${render_field_readonly(form.fieldset.unit_of_measure)} - ${render_field_readonly(form.fieldset.unit)} - ${render_field_readonly(form.fieldset.pack_size)} - ${render_field_readonly(form.fieldset.case_size)} + % for field in panel_fields['main']: + ${form.render_field_readonly(field)} + % endfor ${self.extra_main_fields(form)} </%def> <%def name="left_column()"> - <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> + </nav> ${self.extra_left_panels()} </%def> @@ -104,125 +57,357 @@ <%def name="extra_main_fields(form)"></%def> <%def name="organization_panel()"> - <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> + </nav> </%def> <%def name="render_organization_fields(form)"> - ${render_field_readonly(form.fieldset.department)} - ${render_field_readonly(form.fieldset.subdepartment)} - ${render_field_readonly(form.fieldset.category)} - ${render_field_readonly(form.fieldset.family)} - ${render_field_readonly(form.fieldset.report_code)} + ${form.render_field_readonly('department')} + ${form.render_field_readonly('subdepartment')} + ${form.render_field_readonly('category')} + ${form.render_field_readonly('family')} + ${form.render_field_readonly('report_code')} </%def> <%def name="render_price_fields(form)"> - ${render_field_readonly(form.fieldset.regular_price)} - ${render_field_readonly(form.fieldset.current_price)} - ${render_field_readonly(form.fieldset.current_price_ends)} - ${render_field_readonly(form.fieldset.deposit_link)} - ${render_field_readonly(form.fieldset.tax)} + ${form.render_field_readonly('price_required')} + ${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)"> - ${render_field_readonly(form.fieldset.weighed)} - ${render_field_readonly(form.fieldset.discountable)} - ${render_field_readonly(form.fieldset.special_order)} - ${render_field_readonly(form.fieldset.organic)} - ${render_field_readonly(form.fieldset.not_for_sale)} - ${render_field_readonly(form.fieldset.discontinued)} - ${render_field_readonly(form.fieldset.deleted)} + % for field in panel_fields['flag']: + ${form.render_field_readonly(field)} + % endfor </%def> <%def name="movement_panel()"> - <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> + </nav> </%def> <%def name="render_movement_fields(form)"> - ${render_field_readonly(form.fieldset.last_sold)} + ${form.render_field_readonly('last_sold')} +</%def> + +<%def name="lookup_codes_grid()"> + ${lookup_codes['grid'].render_table_element(data_prop='lookupCodesData')|n} </%def> <%def name="lookup_codes_panel()"> - <div class="panel-grid" id="product-codes"> - <h2>Additional Lookup Codes</h2> - <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> + <nav class="panel item-panel"> + <p class="panel-heading">Additional Lookup Codes</p> + <div class="panel-block"> + ${self.lookup_codes_grid()} </div> - </div> + </nav> +</%def> + +<%def name="sources_grid()"> + ${vendor_sources['grid'].render_table_element(data_prop='vendorSourcesData')|n} </%def> <%def name="sources_panel()"> - <div class="panel-grid" id="product-costs"> - <h2>Vendor Sources</h2> - <div class="grid full no-border"> - <table> - <thead> - <th>Pref.</th> - <th>Vendor</th> - <th>Code</th> - <th>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>${cost.vendor}</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> + <nav class="panel item-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> - </div> + </nav> </%def> <%def name="notes_panel()"> - <div class="panel"> - <h2>Notes</h2> - <div class="panel-body"> - <div class="field">${form.fieldset.notes.render_readonly()}</div> + <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> + </nav> </%def> <%def name="ingredients_panel()"> - <div class="panel"> - <h2>Ingredients</h2> - <div class="panel-body"> - ${render_field_readonly(form.fieldset.ingredients)} + <nav class="panel item-panel"> + <p class="panel-heading">Ingredients</p> + <div class="panel-block"> + ${form.render_field_readonly('ingredients')} </div> - </div> + </nav> </%def> <%def name="extra_left_panels()"></%def> <%def name="extra_right_panels()"></%def> + +<%def name="render_this_page()"> + ${parent.render_this_page()} + % if request.rattail_config.versioning_enabled() and master.has_perm('versions'): + + <b-modal :active.sync="showingPriceHistory_regular" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + Regular Price History + </p> + </header> + <section class="modal-card-body"> + ${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"> + Close + </b-button> + </footer> + </div> + </b-modal> + + <b-modal :active.sync="showingPriceHistory_current" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + Current Price History + </p> + </header> + <section class="modal-card-body"> + ${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"> + Close + </b-button> + </footer> + </div> + </b-modal> + + <b-modal :active.sync="showingPriceHistory_suggested" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + Suggested Price History + </p> + </header> + <section class="modal-card-body"> + ${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"> + Close + </b-button> + </footer> + </div> + </b-modal> + + <b-modal :active.sync="showingCostHistory" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + Cost History + </p> + </header> + <section class="modal-card-body"> + ${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"> + Close + </b-button> + </footer> + </div> + </b-modal> + % endif +</%def> + +<%def name="page_content()"> + <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> + <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> + + % if buttons: + ${buttons|n} + % endif +</%def> + +<%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_table_data()['data'])|n} + ThisPageData.regularPriceHistoryLoading = false + + ThisPage.computed.regularPriceHistoryData = function() { + let data = [] + this.regularPriceHistoryDataRaw.forEach(raw => { + data.push({ + price: raw.price_display, + since: raw.since, + changed: raw.changed_display_html, + changed_by: raw.changed_by_display, + }) + }) + return data + } + + ThisPage.methods.showPriceHistory_regular = function() { + this.showingPriceHistory_regular = true + this.regularPriceHistoryLoading = true + + let url = '${url("products.price_history", uuid=instance.uuid)}' + let params = {'type': 'regular'} + this.$http.get(url, {params: params}).then(response => { + this.regularPriceHistoryDataRaw = response.data + this.regularPriceHistoryLoading = false + }) + } + + ThisPageData.showingPriceHistory_current = false + ThisPageData.currentPriceHistoryDataRaw = ${json.dumps(current_price_history_grid.get_table_data()['data'])|n} + ThisPageData.currentPriceHistoryLoading = false + + ThisPage.computed.currentPriceHistoryData = function() { + let data = [] + this.currentPriceHistoryDataRaw.forEach(raw => { + data.push({ + price: raw.price_display, + price_type: raw.price_type, + since: raw.since, + changed: raw.changed_display_html, + changed_by: raw.changed_by_display, + }) + }) + return data + } + + ThisPage.methods.showPriceHistory_current = function() { + this.showingPriceHistory_current = true + this.currentPriceHistoryLoading = true + + let url = '${url("products.price_history", uuid=instance.uuid)}' + let params = {'type': 'current'} + this.$http.get(url, {params: params}).then(response => { + this.currentPriceHistoryDataRaw = response.data + this.currentPriceHistoryLoading = false + }) + } + + ThisPageData.showingPriceHistory_suggested = false + ThisPageData.suggestedPriceHistoryDataRaw = ${json.dumps(suggested_price_history_grid.get_table_data()['data'])|n} + ThisPageData.suggestedPriceHistoryLoading = false + + ThisPage.computed.suggestedPriceHistoryData = function() { + let data = [] + this.suggestedPriceHistoryDataRaw.forEach(raw => { + data.push({ + price: raw.price_display, + since: raw.since, + changed: raw.changed_display_html, + changed_by: raw.changed_by_display, + }) + }) + return data + } + + ThisPage.methods.showPriceHistory_suggested = function() { + this.showingPriceHistory_suggested = true + this.suggestedPriceHistoryLoading = true + + let url = '${url("products.price_history", uuid=instance.uuid)}' + let params = {'type': 'suggested'} + this.$http.get(url, {params: params}).then(response => { + this.suggestedPriceHistoryDataRaw = response.data + this.suggestedPriceHistoryLoading = false + }) + } + + ThisPageData.showingCostHistory = false + ThisPageData.costHistoryDataRaw = ${json.dumps(cost_history_grid.get_table_data()['data'])|n} + ThisPageData.costHistoryLoading = false + + ThisPage.computed.costHistoryData = function() { + let data = [] + this.costHistoryDataRaw.forEach(raw => { + data.push({ + cost: raw.cost_display, + vendor: raw.vendor, + since: raw.since, + changed: raw.changed_display_html, + changed_by: raw.changed_by_display, + }) + }) + return data + } + + ThisPage.methods.showCostHistory = function() { + this.showingCostHistory = true + this.costHistoryLoading = true + + let url = '${url("products.cost_history", uuid=instance.uuid)}' + this.$http.get(url).then(response => { + this.costHistoryDataRaw = response.data + this.costHistoryLoading = false + }) + } + + % endif + </script> +</%def> diff --git a/tailbone/templates/progress.mako b/tailbone/templates/progress.mako index c378a986..ad0a1371 100644 --- a/tailbone/templates/progress.mako +++ b/tailbone/templates/progress.mako @@ -1,119 +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 updater = null; + let WholePage = { + template: '#whole-page-template', - updater = setInterval(function() {update_progress()}, 1000); + computed: { - $(function() { + totalDisplay() { - $('#cancel button').click(function() { - if (confirm("Do you really wish to cancel this operation?")) { - clearInterval(updater); - $(this).button('disable').button('option', 'label', "Canceling, please wait..."); - $.ajax({ - url: '${url('progress', 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!" + } - }); - - </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> - <td id="cancel"> - <button type="button" style="display: none;">Cancel</button> - </td> - </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) { - location.href = '${cancel_url}'; - } else if (data.complete || data.maximum) { - $('#message').html(data.message); - $('#total').html('('+data.maximum_display+' total)'); - $('#cancel button').show(); - if (data.complete) { - clearInterval(updater); - $('#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 (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 3a165f01..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(${form.fieldset.model.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/batches/view.mako b/tailbone/templates/purchases/batches/view.mako deleted file mode 100644 index f7870fb1..00000000 --- a/tailbone/templates/purchases/batches/view.mako +++ /dev/null @@ -1,43 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/batch/view.mako" /> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - $(function() { - - $('#order-form').click(function() { - % if vendor_cost_count > vendor_cost_threshold: - if (! confirm("This vendor has ${'{:,d}'.format(vendor_cost_count)} cost records.\n\n" + - "It is not recommended to use Order Form mode for such a large catalog.\n\n" + - "Are you sure you wish to do it anyway?")) { - return; - } - % endif - $(this).button('disable').button('option', 'label', "Working, please wait..."); - location.href = '${url('purchases.batch.order_form', uuid=batch.uuid)}'; - }); - - $('#receive-form').click(function() { - $(this).button('disable').button('option', 'label', "Working, please wait..."); - location.href = '${url('purchases.batch.receiving_form', uuid=batch.uuid)}'; - }); - - }); - </script> -</%def> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/purchases.css'))} -</%def> - -<%def name="leading_buttons()"> - % if batch.mode == enum.PURCHASE_BATCH_MODE_ORDERING and not batch.complete and not batch.executed and request.has_perm('purchases.batch.order_form'): - <button type="button" id="order-form">Ordering Form</button> - % elif batch.mode == enum.PURCHASE_BATCH_MODE_RECEIVING and not batch.complete and not batch.executed and request.has_perm('purchases.batch.receiving_form'): - <button type="button" id="receive-form">Receiving Form</button> - % endif -</%def> - -${parent.body()} 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/purchases/receiving_worksheet.mako b/tailbone/templates/purchases/receiving_worksheet.mako index 38696dd4..596bfd43 100644 --- a/tailbone/templates/purchases/receiving_worksheet.mako +++ b/tailbone/templates/purchases/receiving_worksheet.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <html> <head> <title>Receiving Worksheet</title> @@ -76,7 +76,7 @@ <tbody> % for item in purchase.items: <tr> - <td>${item.upc.pretty()}</td> + <td>${item.upc.pretty() if item.upc else item.item_id}</td> <td>${item.vendor_code or ''}</td> <td>${(item.brand_name or '')[:15]}</td> <td>${item.description or ''}</td> 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 new file mode 100644 index 00000000..35ee878a --- /dev/null +++ b/tailbone/templates/receiving/create.mako @@ -0,0 +1,6 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/create.mako" /> + +## TODO: deprecate / remove this + +${parent.body()} diff --git a/tailbone/templates/receiving/declare_credit.mako b/tailbone/templates/receiving/declare_credit.mako new file mode 100644 index 00000000..a377e270 --- /dev/null +++ b/tailbone/templates/receiving/declare_credit.mako @@ -0,0 +1,52 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + +<%def name="title()">Declare Credit for Row #${row.sequence}</%def> + +<%def name="context_menu_items()"> + ${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="render_form()"> + + <p class="block"> + Please select the "state" of the product, and enter the + appropriate quantity. + </p> + + <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> + + <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> + + ${parent.render_form()} + +</%def> + +<%def name="form_body()"> + + ${form.render_field_complete('credit_type')} + + ${form.render_field_complete('quantity')} + + ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_credit_type == 'expired'"})} + +</%def> + +<%def name="render_form_template()"> + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako new file mode 100644 index 00000000..48dc6755 --- /dev/null +++ b/tailbone/templates/receiving/receive_row.mako @@ -0,0 +1,49 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + +<%def name="title()">Receive for Row #${row.sequence}</%def> + +<%def name="context_menu_items()"> + ${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="render_form()"> + + <p class="block"> + Please select the "state" of the product, and enter the appropriate + quantity. + </p> + + <p class="block"> + Note that this tool will <span class="has-text-weight-bold">add</span> + the corresponding quantities for the row. + </p> + + <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> + + ${parent.render_form()} + +</%def> + +<%def name="form_body()"> + + ${form.render_field_complete('mode')} + + ${form.render_field_complete('quantity')} + + ${form.render_field_complete('expiration_date', bfield_attrs={'v-show': "field_model_mode == 'expired'"})} + +</%def> + +<%def name="render_form_template()"> + ${form.render_deform(buttons=capture(self.render_form_buttons), form_body=capture(self.form_body))|n} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/receiving/transform_unit_row.mako b/tailbone/templates/receiving/transform_unit_row.mako new file mode 100644 index 00000000..d003228c --- /dev/null +++ b/tailbone/templates/receiving/transform_unit_row.mako @@ -0,0 +1,16 @@ +## -*- coding: utf-8; -*- + +<p> + This row is associated with a "pack" item, but you may transform it, so it + associates with the "unit" item instead: +</p> + +<br /> + +${diff.render_html()} + +<br /> +<p> + Transforming to the unit item may help with "claiming" between Truck Dump + parent and child rows. +</p> diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako new file mode 100644 index 00000000..710dec4a --- /dev/null +++ b/tailbone/templates/receiving/view.mako @@ -0,0 +1,390 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/view.mako" /> + +<%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; + } + tr.catalog_cost_confirmed td.c_catalog_unit_cost { + background-color: #cfc; + } + % endif + % if allow_edit_invoice_unit_cost: + td.c_invoice_unit_cost { + cursor: pointer; + background-color: #fcc; + } + tr.invoice_cost_confirmed td.c_invoice_unit_cost { + background-color: #cfc; + } + % endif + </style> +</%def> + +<%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="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()"> + ${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> + + % if allow_confirm_all_costs: + + ThisPageData.confirmAllCostsShowDialog = false + ThisPageData.confirmAllCostsSubmitting = false + + % 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 new file mode 100644 index 00000000..086754c6 --- /dev/null +++ b/tailbone/templates/receiving/view_row.mako @@ -0,0 +1,722 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view_row.mako" /> + +<%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> + +<%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/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 new file mode 100644 index 00000000..cce6f346 --- /dev/null +++ b/tailbone/templates/reports/generated/view.mako @@ -0,0 +1,33 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%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> + +<%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 235765f9..89dd56c3 100644 --- a/tailbone/templates/roles/create.mako +++ b/tailbone/templates/roles/create.mako @@ -6,4 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -${parent.body()} +<%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 + ${form.vue_component}Data.showingPermissionGroup = '' + </script> +</%def> diff --git a/tailbone/templates/roles/edit.mako b/tailbone/templates/roles/edit.mako index 6f89b44d..e77cca33 100644 --- a/tailbone/templates/roles/edit.mako +++ b/tailbone/templates/roles/edit.mako @@ -6,4 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -${parent.body()} +<%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 + ${form.vue_component}Data.showingPermissionGroup = '' + </script> +</%def> 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/index.mako b/tailbone/templates/roles/index.mako new file mode 100644 index 00000000..857e5f6a --- /dev/null +++ b/tailbone/templates/roles/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/principal/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('download_permissions_matrix'): + <li>${h.link_to("Download Permissions Matrix", url('roles.download_permissions_matrix'))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index 3589ca41..f5588695 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -6,17 +6,20 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -${parent.body()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> -<h2>Users</h2> + % if users_data is not Undefined: + ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n} + % endif -% 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 + ThisPage.methods.detachPerson = function(url) { + ## 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 + } + } + + </script> +</%def> diff --git a/tailbone/templates/settings/crud.mako b/tailbone/templates/settings/crud.mako deleted file mode 100644 index 84ce606e..00000000 --- a/tailbone/templates/settings/crud.mako +++ /dev/null @@ -1,13 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to Settings", url('settings'))}</li> - % if form.readonly: - <li>${h.link_to("Edit this Setting", url('settings.edit', name=form.fieldset.model.name))}</li> - % elif form.updating: - <li>${h.link_to("View this Setting", url('settings.view', name=form.fieldset.model.name))}</li> - % endif -</%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 new file mode 100644 index 00000000..73ad7066 --- /dev/null +++ b/tailbone/templates/settings/email/view.mako @@ -0,0 +1,108 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="render_form()"> + ${parent.render_form()} + <email-preview-tools></email-preview-tools> +</%def> + +<%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'})} + ${h.csrf_token(request)} + ${h.hidden('email_key', value=instance['key'])} + + <br /> + + <div class="field is-grouped"> + + <div class="control"> + % if email.get_template('html'): + <a class="button is-primary" + href="${url('email.preview')}?key=${instance['key']}&type=html" + target="_blank"> + Preview HTML + </a> + % else: + <button class="button is-primary" + type="button" + title="There is no HTML template on file for this email." + disabled> + Preview HTML + </button> + % endif + </div> + + <div class="control"> + % if email.get_template('txt'): + <a class="button is-primary" + href="${url('email.preview')}?key=${instance['key']}&type=txt" + target="_blank"> + Preview TXT + </a> + % else: + <button class="button is-primary" + type="button" + title="There is no TXT template on file for this email." + disabled> + Preview TXT + </button> + % endif + </div> + + <div class="control"> + or + </div> + + <div class="control"> + <b-input name="recipient" v-model="userEmailAddress"></b-input> + </div> + + <div class="control"> + <b-button type="is-primary" + native-type="submit" + :disabled="previewFormSubmitting"> + {{ previewFormButtonText }} + </b-button> + </div> + + </div><!-- field --> + + ${h.end_form()} + </script> + <script type="text/javascript"> + + const EmailPreviewTools = { + template: '#email-preview-tools-template', + data() { + return { + previewFormButtonText: "Send Preview Email", + previewFormSubmitting: false, + userEmailAddress: ${json.dumps(user_email_address)|n}, + } + }, + methods: { + submitPreviewForm(event) { + if (!this.userEmailAddress) { + alert("Please provide an email address.") + event.preventDefault() + return + } + this.previewFormSubmitting = true + this.previewFormButtonText = "Working, please wait..." + } + } + } + + </script> +</%def> + +<%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 051e57db..52b48832 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -1,103 +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() { - - $('#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 #date').val($(this).data('date')); - $('#filter-form').submit(); - } - }); - - $('.week-picker #date').datepicker({ - dateFormat: 'mm/dd/yy', - changeYear: true, - changeMonth: true, - showButtonPanel: true, - onSelect: function(dateText, inst) { - $('#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 { @@ -134,8 +44,8 @@ <%def name="timesheet_wrapper(with_edit_form=False, change_employee=None)"> <div class="timesheet-wrapper"> - ${form.begin(id='filter-form')} - ${form.csrf_token()} + ${h.form(request.current_route_url(_query=False), id='filter-form')} + ${h.csrf_token(request)} <table class="timesheet-header"> <tbody> @@ -147,26 +57,31 @@ <div class="field-wrapper employee"> <label>Employee</label> <div class="field"> - % if request.has_perm('{}.viewall'.format(permission_prefix)): - ${autocomplete('employee', url('employees.autocomplete'), - field_value=employee.uuid if employee else None, - field_display=unicode(employee or ''), - selected='employee_selected', - change_clicked=change_employee)} - % else: - ${form.hidden('employee', value=employee.uuid)} - ${employee} - % endif + ${dform['employee'].serialize(text=str(employee), selected_callback='employee_selected')|n} </div> </div> % endif % if store_options is not Undefined: - ${form.field_div('store', h.select('store', store.uuid if store else None, store_options))} + <div class="field-wrapper store"> + <div class="field-row"> + <label for="store">Store</label> + <div class="field"> + ${dform['store'].serialize()|n} + </div> + </div> + </div> % endif % if department_options is not Undefined: - ${form.field_div('department', h.select('department', department.uuid if department else None, department_options))} + <div class="field-wrapper department"> + <div class="field-row"> + <label for="department">Department</label> + <div class="field"> + ${dform['department'].serialize()|n} + </div> + </div> + </div> % endif <div class="field-wrapper week"> @@ -190,11 +105,11 @@ <tr> <td class="tools"> <div class="grid-tools"> - <div class="week-picker"> - <button type="button" class="nav" data-date="${prev_sunday.strftime('%m/%d/%Y')}">« Previous</button> - <button type="button" class="nav" data-date="${next_sunday.strftime('%m/%d/%Y')}">Next »</button> + <div class="week-picker" data-week="${sunday.strftime('%Y-%m-%d')}"> + <button type="button" class="nav" data-date="${prev_sunday.strftime('%Y-%m-%d')}">« Previous</button> + <button type="button" class="nav" data-date="${next_sunday.strftime('%Y-%m-%d')}">Next »</button> <label>Jump to week:</label> - ${form.text('date', value=sunday.strftime('%m/%d/%Y'))} + ${dform['date'].serialize(extra_options={'showButtonPanel': True}, selected_callback='date_selected')|n} </div> </div><!-- grid-tools --> </td><!-- tools --> @@ -203,7 +118,7 @@ </tbody> </table><!-- timesheet-header --> - ${form.end()} + ${h.end_form()} % if with_edit_form: ${self.edit_form()} @@ -218,10 +133,10 @@ </div><!-- timesheet-wrapper --> </%def> -<%def name="timesheet(render_day=None)"> +<%def name="timesheet(render_day=None, extra_columns=0)"> <style type="text/css"> .timesheet thead th { - width: ${'{:0.2f}'.format(100.0 / 9)}%; + width: ${'{:0.2f}'.format(100.0 / (9 + extra_columns))}%; } </style> @@ -233,10 +148,11 @@ <th>${day.strftime('%A')}<br />${day.strftime('%b %d')}</th> % endfor <th>Total<br />Hours</th> + ${self.render_extra_headers()} </tr> </thead> <tbody> - % for emp in sorted(employees, key=unicode): + % 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... @@ -254,6 +170,7 @@ <td class="total"> ${self.render_employee_total(emp)} </td> + ${self.render_extra_cells(employee)} </tr> % endfor % if employee is Undefined: @@ -275,6 +192,7 @@ <td> ${self.render_employee_total(employee)} </td> + ${self.render_extra_totals(employee)} </tr> % endif </tbody> @@ -291,5 +209,15 @@ <%def name="render_employee_day_total(day)"></%def> +<%def name="render_extra_headers()"></%def> -${self.timesheet_wrapper()} +<%def name="render_extra_cells(employee)"></%def> + +<%def name="render_extra_totals(employee)"></%def> + +<%def name="page_content()"> + ${self.timesheet_wrapper()} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/shifts/schedule.mako b/tailbone/templates/shifts/schedule.mako index 61a69c32..ec2a136c 100644 --- a/tailbone/templates/shifts/schedule.mako +++ b/tailbone/templates/shifts/schedule.mako @@ -6,7 +6,11 @@ <li>${h.link_to("Edit Schedule", url('schedule.edit'))}</li> % endif % if request.has_perm('schedule.print'): - <li>${h.link_to("Print Schedule", url('schedule.print'), target='_blank')}</li> + % if employee is Undefined: + <li>${h.link_to("Print Schedule", url('schedule.print'), target='_blank')}</li> + % else: + <li>${h.link_to("Print this Schedule", url('schedule.employee.print'), target='_blank')}</li> + % endif % endif % if request.has_perm('timesheet.view'): <li>${h.link_to("View this Time Sheet", url('schedule.goto.timesheet'), class_='goto')}</li> diff --git a/tailbone/templates/shifts/schedule_edit.mako b/tailbone/templates/shifts/schedule_edit.mako index 04cf7af4..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; - } - $(event.target).button('disable').button('option', 'label', "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/schedule_print_employee.mako b/tailbone/templates/shifts/schedule_print_employee.mako new file mode 100644 index 00000000..0ceddd88 --- /dev/null +++ b/tailbone/templates/shifts/schedule_print_employee.mako @@ -0,0 +1,20 @@ +## -*- coding: utf-8; -*- +<%namespace file="/shifts/base.mako" import="timesheet" /> +<%namespace file="/shifts/schedule.mako" import="render_day" /> +<html> + <head> + ## TODO: this seems a little hacky..? + ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'), media='all')} + ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css'), media='all')} + ${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css'), media='all')} + ${h.stylesheet_link(request.static_url('tailbone:static/css/timesheet.css'), media='all')} + ${h.stylesheet_link(request.static_url('tailbone:static/css/schedule_print.css'), media='print')} + </head> + <body> + <h1> + ${employee} - + ${week_of} + </h1> + ${timesheet(render_day=render_day)} + </body> +</html> diff --git a/tailbone/templates/shifts/timesheet.mako b/tailbone/templates/shifts/timesheet.mako index 45e103c0..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()"> @@ -21,8 +21,8 @@ </%def> <%def name="render_employee_day_total(day)"> - ${day['worked_hours_display']} + <span title="${h.hours_as_decimal(day['worked_hours'])} hrs">${day['worked_hours_display']}</span> </%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/tables/index.mako b/tailbone/templates/tables/index.mako new file mode 100644 index 00000000..b13f0785 --- /dev/null +++ b/tailbone/templates/tables/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % 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/index.mako b/tailbone/templates/tempmon/appliances/index.mako new file mode 100644 index 00000000..910cea35 --- /dev/null +++ b/tailbone/templates/tempmon/appliances/index.mako @@ -0,0 +1,38 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + .grid .image-frame { + height: 150px; + text-align: center; + white-space: nowrap; + width: 150px; + } + + .grid .image-helper { + display: inline-block; + height: 100%; + vertical-align: middle; + } + + .grid img { + vertical-align: middle; + max-height: 150px; + max-width: 150px; + } + + </style> +</%def> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako new file mode 100644 index 00000000..a55af922 --- /dev/null +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -0,0 +1,16 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + +<%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/index.mako b/tailbone/templates/tempmon/clients/index.mako new file mode 100644 index 00000000..829f9159 --- /dev/null +++ b/tailbone/templates/tempmon/clients/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index 2a508f73..434da4c8 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -1,22 +1,30 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="head_tags()"> - ${parent.head_tags()} - <script type="text/javascript"> - $(function() { - $('#restart-client').click(function() { - $(this).button('disable').button('option', 'label', "Restarting, please wait..."); - location.href = '${url('tempmon.clients.restart', uuid=instance.uuid)}'; - }); - }); - </script> +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif </%def> -${parent.body()} +<%def name="object_helpers()"> + % if instance.enabled and master.restartable_client(instance) and request.has_perm('{}.restart'.format(route_prefix)): + <div class="object-helper"> + <h3>Client Tools</h3> + <div class="object-helper-content"> + <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> -% if instance.enabled and master.restartable_client(instance) and request.has_perm('tempmon.clients.restart'): - <div class="buttons"> - <button type="button" id="restart-client">Restart this Client</button> - </div> -% endif +<%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 new file mode 100644 index 00000000..befaf8b4 --- /dev/null +++ b/tailbone/templates/tempmon/dashboard.mako @@ -0,0 +1,120 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">TempMon Appliances » Dashboard</%def> + +<%def name="content_title()">Dashboard</%def> + +<%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> +</%def> + +<%def name="render_this_page()"> + ${h.form(request.current_route_url(), ref='applianceForm')} + ${h.csrf_token(request)} + <div class="level-left"> + + <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 + } + + for (let chart in this.charts) { + chart.destroy() + } + this.charts = [] + + let url = '${url('tempmon.dashboard.readings')}' + let params = {appliance_uuid: uuid} + this.$http.get(url, {params: params}).then(response => { + if (response.data.probes) { + + 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: [{ + label: probe.description, + data: probe.readings + }] + }, + options: { + scales: { + xAxes: [{ + type: 'time', + time: {unit: 'minute'}, + position: 'bottom' + }] + } + } + }) + } + + } else { + alert(response.data.error) + } + }) + } + + ThisPage.mounted = function() { + this.fetchReadings() + } + + </script> +</%def> diff --git a/tailbone/templates/tempmon/probes/create.mako b/tailbone/templates/tempmon/probes/create.mako deleted file mode 100644 index 95dbd3d6..00000000 --- a/tailbone/templates/tempmon/probes/create.mako +++ /dev/null @@ -1,17 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/create.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/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 new file mode 100644 index 00000000..94a440e0 --- /dev/null +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -0,0 +1,130 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + +<%def name="title()">Temperature Graph</%def> + +<%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> +</%def> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if master.has_perm('view'): + <li>${h.link_to("View this {}".format(model_title), master.get_action_url('view', probe))}</li> + % endif + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + +<%def name="render_this_page()"> + <div style="display: flex; justify-content: space-between;"> + + <div class="form-wrapper"> + <div class="form"> + + <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> + + <b-field horizontal label="Probe Location"> + <div> + ${probe.location or ""} + </div> + </b-field> + + <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> + + <div style="display: flex; align-items: flex-start;"> + <div class="object-helpers"> + ${self.object_helpers()} + </div> + + <ul id="context-menu"> + ${self.context_menu_items()} + </ul> + </div> + + </div> + + <canvas ref="tempchart" width="400" height="150"></canvas> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.currentTimeRange = ${json.dumps(current_time_range)|n} + ThisPageData.chart = null + + ThisPage.methods.fetchReadings = function(timeRange) { + + if (timeRange === undefined) { + timeRange = this.currentTimeRange + } + + let timeUnit = null + if (timeRange == 'last hour') { + timeUnit = 'minute' + } else if (['last 6 hours', 'last day'].includes(timeRange)) { + timeUnit = 'hour' + } else { + timeUnit = 'day' + } + + if (this.chart) { + this.chart.destroy() + } + + 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', + data: { + datasets: [{ + label: "${probe.description}", + data: data + }] + }, + options: { + scales: { + xAxes: [{ + type: 'time', + time: {unit: timeUnit}, + position: 'bottom' + }] + } + } + }); + + }) + } + + ThisPage.methods.timeRangeChanged = function(value) { + this.fetchReadings(value) + } + + ThisPage.mounted = function() { + this.fetchReadings() + } + + </script> +</%def> diff --git a/tailbone/templates/tempmon/probes/index.mako b/tailbone/templates/tempmon/probes/index.mako new file mode 100644 index 00000000..829f9159 --- /dev/null +++ b/tailbone/templates/tempmon/probes/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/probes/view.mako b/tailbone/templates/tempmon/probes/view.mako new file mode 100644 index 00000000..7afd2427 --- /dev/null +++ b/tailbone/templates/tempmon/probes/view.mako @@ -0,0 +1,96 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%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> + + <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> +</%def> + + +############################## +## rendering methods +############################## + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + <li>${h.link_to("View Readings as Graph", action_url('graph', instance))}</li> + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + +<%def name="render_main_fields(form)"> + ${form.render_field_readonly('client')} + ${form.render_field_readonly('config_key')} + ${form.render_field_readonly('appliance')} + ${form.render_field_readonly('appliance_type')} + ${form.render_field_readonly('description')} + ${form.render_field_readonly('location')} + ${form.render_field_readonly('device_path')} + ${form.render_field_readonly('notes')} + ${form.render_field_readonly('enabled')} + ${form.render_field_readonly('status')} + ${form.render_field_readonly('therm_status_timeout')} + ${form.render_field_readonly('status_alert_timeout')} +</%def> + +<%def name="left_column()"> + <nav class="panel"> + <p class="panel-heading">Temperatures</p> + <div class="panel-block"> + <div> + ${self.render_temperature_fields(form)} + </div> + </div> + </nav> +</%def> + +<%def name="right_column()"> + <nav class="panel"> + <p class="panel-heading">Timeouts</p> + <div class="panel-block"> + <div> + ${self.render_timeout_fields(form)} + </div> + </div> + </nav> +</%def> + +<%def name="render_temperature_fields(form)"> + ${form.render_field_readonly('critical_temp_max')} + ${form.render_field_readonly('good_temp_max')} + ${form.render_field_readonly('good_temp_min')} + ${form.render_field_readonly('critical_temp_min')} +</%def> + +<%def name="render_timeout_fields(form)"> + ${form.render_field_readonly('critical_max_timeout')} + ${form.render_field_readonly('good_max_timeout')} + ${form.render_field_readonly('good_min_timeout')} + ${form.render_field_readonly('critical_min_timeout')} + ${form.render_field_readonly('error_timeout')} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/readings/index.mako b/tailbone/templates/tempmon/readings/index.mako new file mode 100644 index 00000000..829f9159 --- /dev/null +++ b/tailbone/templates/tempmon/readings/index.mako @@ -0,0 +1,12 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('tempmon.appliances.dashboard'): + <li>${h.link_to("Go to the Dashboard", url('tempmon.dashboard'))}</li> + % endif +</%def> + + +${parent.body()} 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/falafel/base.mako b/tailbone/templates/themes/falafel/base.mako new file mode 100644 index 00000000..1869043b --- /dev/null +++ b/tailbone/templates/themes/falafel/base.mako @@ -0,0 +1,4 @@ +## -*- coding: utf-8; -*- +<%inherit file="tailbone:templates/base.mako" /> + +${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 new file mode 100644 index 00000000..4815fc79 --- /dev/null +++ b/tailbone/templates/units-of-measure/index.mako @@ -0,0 +1,67 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="grid_tools()"> + ${parent.grid_tools()} + + % if master.has_perm('collect_wild_uoms'): + <b-button type="is-primary" + icon-pack="fas" + icon-left="shopping-basket" + @click="showingCollectWildDialog = true"> + Collect from the Wild + </b-button> + + ${h.form(url('{}.collect_wild_uoms'.format(route_prefix)), ref='collect-wild-uoms-form')} + ${h.csrf_token(request)} + ${h.end_form()} + + <b-modal has-modal-card + :active.sync="showingCollectWildDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Collect from the Wild</p> + </header> + + <section class="modal-card-body"> + <p> + This tool will query some database(s) in order to discover all UOM + abbreviations which currently exist in your product data. + </p> + <p> + Depending on how it has to go about that, this could take a minute or + two. Please be patient when running it. + </p> + </section> + + <footer class="modal-card-foot"> + <b-button @click="showingCollectWildDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="collectFromWild()" + icon-left="shopping-basket" + text="Collect from the Wild"> + </once-button> + </footer> + + </div> + </b-modal> + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if master.has_perm('collect_wild_uoms'): + <script> + + ${grid.vue_component}Data.showingCollectWildDialog = false + + ${grid.vue_component}.methods.collectFromWild = function() { + this.$refs['collect-wild-uoms-form'].submit() + } + + </script> + % endif +</%def> diff --git a/tailbone/templates/upgrade.mako b/tailbone/templates/upgrade.mako index bb512510..7cc73941 100644 --- a/tailbone/templates/upgrade.mako +++ b/tailbone/templates/upgrade.mako @@ -1,79 +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) { - clearInterval(updater); - $('#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'); - } - } - } - } - }); - } - </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 1c9b3ec5..c3fca81d 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -1,54 +1,289 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <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; } - } + </style> + % endif +</%def> - $(function() { +<%def name="render_this_page()"> + ${parent.render_this_page()} - show_packages('diffs'); + % 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"> - $('.showing .all').click(function() { - show_packages('all'); - return false; - }); + <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> - $('.showing .diffs').click(function() { - show_packages('diffs') - return false; - }); + <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 instance_executable and master.has_perm('execute'): + <div class="buttons"> + % if instance.enabled and not instance.executing: + % if expose_websockets: + <b-button type="is-primary" + 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.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 + % elif instance.enabled: + <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button> + % else: + <button type="button" class="button is-primary" disabled="disabled" title="This upgrade is not enabled">Execute this upgrade</button> + % endif + </div> + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${form.vue_component}Data.showingPackages = 'diffs' + + % if master.has_perm('execute'): + + % 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()} - -% if not instance.executed and request.has_perm('{}.execute'.format(permission_prefix)): - <div class="buttons"> - % if instance.enabled and not instance.executing: - ${h.form(url('{}.execute'.format(route_prefix), uuid=instance.uuid), class_='autodisable')} - ${h.csrf_token(request)} - ${h.submit('execute', "Execute this upgrade")} - ${h.end_form()} - % elif instance.enabled: - <button type="button" disabled="disabled" title="This upgrade is currently executing">Execute this upgrade</button> - % else: - <button type="button" disabled="disabled" title="This upgrade is not enabled">Execute this upgrade</button> - % endif - </div> -% endif 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 c5b1980f..d1afd218 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -1,9 +1,136 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%namespace file="/util.mako" import="view_profiles_helper" /> <%def name="extra_styles()"> ${parent.extra_styles()} ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -${parent.body()} +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if instance.person: + ${view_profiles_helper([instance.person])} + % endif +</%def> + +<%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 new file mode 100644 index 00000000..19a1b89d --- /dev/null +++ b/tailbone/templates/util.mako @@ -0,0 +1,28 @@ +## -*- coding: utf-8; -*- + +<%def name="view_profile_button(person)"> + <div class="buttons"> + <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'): + <nav class="panel"> + <p class="panel-heading">Profiles</p> + <div class="panel-block"> + <div style="display: flex; flex-direction: column;"> + <p class="block">View full profile for:</p> + % for person in people: + ${view_profile_button(person)} + % endfor + </div> + </div> + </nav> + % endif +</%def> diff --git a/tailbone/templates/vendors/catalogs/create.mako b/tailbone/templates/vendors/catalogs/create.mako deleted file mode 100644 index ccb7d21a..00000000 --- a/tailbone/templates/vendors/catalogs/create.mako +++ /dev/null @@ -1,55 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/batch/create.mako" /> - -<%def name="extra_javascript()"> - ${parent.extra_javascript()} - <script type="text/javascript"> - - var vendormap = { - % for i, parser in enumerate(parsers, 1): - '${parser.key}': ${parser.vendormap_value|n}${',' if i < len(parsers) else ''} - % endfor - }; - - $(function() { - - if ($('#VendorCatalog--parser_key option:first').is(':selected')) { - $('#VendorCatalog--vendor_uuid-container').hide(); - } else { - $('#VendorCatalog--vendor_uuid').val(''); - $('#VendorCatalog--vendor_uuid-display').hide(); - $('#VendorCatalog--vendor_uuid-display button').show(); - $('#VendorCatalog--vendor_uuid-textbox').val(''); - $('#VendorCatalog--vendor_uuid-textbox').show(); - $('#VendorCatalog--vendor_uuid-container').show(); - } - - $('#VendorCatalog--parser_key').change(function() { - if ($(this).find('option:first').is(':selected')) { - $('#VendorCatalog--vendor_uuid-container').hide(); - } else { - var vendor = vendormap[$(this).val()]; - if (vendor) { - $('#VendorCatalog--vendor_uuid').val(vendor.uuid); - $('#VendorCatalog--vendor_uuid-textbox').hide(); - $('#VendorCatalog--vendor_uuid-display span:first').text(vendor.name); - $('#VendorCatalog--vendor_uuid-display button').hide(); - $('#VendorCatalog--vendor_uuid-display').show(); - $('#VendorCatalog--vendor_uuid-container').show(); - } else { - $('#VendorCatalog--vendor_uuid').val(''); - $('#VendorCatalog--vendor_uuid-display').hide(); - $('#VendorCatalog--vendor_uuid-display button').show(); - $('#VendorCatalog--vendor_uuid-textbox').val(''); - $('#VendorCatalog--vendor_uuid-textbox').show(); - $('#VendorCatalog--vendor_uuid-container').show(); - $('#VendorCatalog--vendor_uuid-textbox').focus(); - } - } - }); - - }); - </script> -</%def> - -${parent.body()} 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 bc6a3a93..9c06c1be 100644 --- a/tailbone/tweens.py +++ b/tailbone/tweens.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. # @@ -24,35 +24,48 @@ Tween Factories """ -from __future__ import unicode_literals, absolute_import - from sqlalchemy.exc import OperationalError -from transaction.interfaces import TransientError def sqlerror_tween_factory(handler, registry): """ Produces a tween which will convert ``sqlalchemy.exc.OperationalError`` - instances (caused by database server restart) into a retryable - ``transaction.interfaces.TransientError`` instance, so that a second - attempt may be made to connect to the database before really giving up. + instances (caused by database server restart) into a retryable error + instance, so that a second attempt may be made to connect to the database + before really giving up. .. note:: This tween alone is not enough to cause the transaction to be retried; it only marks the error as being *retryable*. If you wish more than one - attempt to be made, you must define the ``tm.attempts`` setting within - your Pyramid app configuration. For more info see `Retrying`_. + attempt to be made, you must define the ``retry.attempts`` (or + ``tm.attempts`` if running pyramid<1.9) setting within your Pyramid app + configuration. For more info see `Retrying`_. .. _Retrying: http://docs.pylonsproject.org/projects/pyramid_tm/en/latest/#retrying """ def sqlerror_tween(request): + try: + from pyramid_retry import mark_error_retryable + except ImportError: + mark_error_retryable = None + from transaction.interfaces import TransientError + try: response = handler(request) except OperationalError as error: + + # if connection is invalid, allow retry if error.connection_invalidated: - raise TransientError(str(error)) + if mark_error_retryable: + mark_error_retryable(error) + raise error + else: + raise TransientError(str(error)) + + # if connection was *not* invalid, raise original error raise + return response return sqlerror_tween diff --git a/tailbone/util.py b/tailbone/util.py index 0df88477..71aa35e3 100644 --- a/tailbone/util.py +++ b/tailbone/util.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,26 +24,112 @@ Utilities """ -from __future__ import unicode_literals, absolute_import - import datetime +import importlib +import logging +import warnings -import pytz import humanize +import markdown -from rattail.time import timezone, make_utc +from rattail.files import resource_path +import colander +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): + """ """ + 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'): + """ """ + warnings.warn("tailbone.util.csrf_token() is deprecated; " + "please use wuttaweb.util.render_csrf_token() instead", + DeprecationWarning, stacklevel=2) + return render_csrf_token(request, name=name) + + +def get_form_data(request): """ - Convenience function. Returns CSRF hidden tag inside hidden DIV. + DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()` + instead. """ - token = request.session.get_csrf_token() - if token is None: - token = request.session.new_csrf_token() - return HTML.tag("div", tags.hidden(name, value=token), style="display:none;") + warnings.warn("tailbone.util.get_form_data() is deprecated; " + "please use wuttaweb.util.get_form_data() instead", + DeprecationWarning, stacklevel=2) + return wutta_get_form_data(request) + + +def 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): @@ -59,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', @@ -76,7 +164,7 @@ def pretty_datetime(config, value): c=humanize.naturaltime(time_ago)) -def raw_datetime(config, value): +def raw_datetime(config, value, verbose=False, as_date=False): """ Formats a datetime as a "raw" human-readable string, with a tooltip showing the more human-friendly "time since" equivalent. @@ -89,28 +177,225 @@ def raw_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)) kwargs = {} # Avoid strftime error when year falls before epoch. if value.year >= 1900: - kwargs['c'] = value.strftime('%Y-%m-%d %I:%M:%S %p') + if as_date: + kwargs['c'] = value.strftime('%Y-%m-%d') + else: + kwargs['c'] = value.strftime('%Y-%m-%d %I:%M:%S %p') else: - kwargs['c'] = unicode(value) + kwargs['c'] = str(value) - # Avoid humanize error when calculating huge time diff. - if abs(time_ago.days) < 100000: - kwargs['title'] = 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: + kwargs['c'] = "{} ({})".format(kwargs['c'], time_diff) + + # vs. if *not* verbose, text is "YYYY-MM-DD" but we add "X days ago" as title + else: + kwargs['title'] = time_diff 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 + directory path, i.e. theme for all users will change immediately. + + This also saves the setting for the new theme, and updates the running app + registry settings with the new theme. + """ + theme = get_effective_theme(request.rattail_config, theme=theme, session=session) + theme_path = get_theme_template_path(request.rattail_config, theme=theme, session=session) + + # there's only one global template lookup; can get to it via any renderer + # but should *not* use /base.mako since that one is about to get volatile + renderer = get_renderer('/menu.mako') + lookup = renderer.lookup + + # overwrite first entry in lookup's directory list + lookup.directories[0] = theme_path + + # clear template cache for lookup object, so it will reload each (as needed) + lookup._collection.clear() + + 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 + + +def get_theme_template_path(rattail_config, theme=None, session=None): + """ + Retrieves the template path for the given theme. + """ + theme = get_effective_theme(rattail_config, theme=theme, session=session) + theme_path = rattail_config.get('tailbone', 'theme.{}'.format(theme), + default='tailbone:templates/themes/{}'.format(theme)) + 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. + """ + app = rattail_config.get_app() + + if not theme: + 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 = 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 + ``colander`` package for actual validation logic. + """ + node = colander.SchemaNode(typ=colander.String) + validator = colander.Email() + validator(node, address) + return address + + +def email_address_is_valid(address): + """ + Returns boolean indicating whether the address can validate. + """ + try: + validate_email_address(address) + except colander.Invalid: + return False + return True + + +def route_exists(request, route_name): + """ + Checks for existence of the given route name, within the running app + config. Returns boolean indicating whether it exists. + """ + reg = request.registry + 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 d8090a19..29c73b61 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -27,12 +27,7 @@ Pyramid Views from __future__ import unicode_literals, absolute_import from .core import View -from .master import MasterView -from .master2 import MasterView2 -from .master3 import MasterView3 - -# TODO: deprecate / remove some of this -from .autocomplete import AutocompleteView +from .master import MasterView, ViewSupplement def includeme(config): @@ -74,4 +69,6 @@ def includeme(config): config.include('tailbone.views.vendors') # batch views + config.include('tailbone.views.batch.newproduct') config.include('tailbone.views.batch.pricing') + config.include('tailbone.views.batch.product') 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 de6e33ee..eceab803 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.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,51 +24,24 @@ Auth Views """ -from __future__ import unicode_literals, absolute_import - -from rattail.db.auth import authenticate_user, set_user_password - -import formencode as fe +import colander +from deform import widget as dfwidget from pyramid.httpexceptions import HTTPForbidden -from pyramid_simpleform import Form from webhelpers2.html import tags, literal from tailbone import forms from tailbone.db import Session from tailbone.views import View from tailbone.auth import login_user, logout_user +from tailbone.config import global_help_url -class UserLogin(fe.Schema): - allow_extra_fields = True - filter_extra_fields = True - username = fe.validators.NotEmpty() - password = fe.validators.NotEmpty() +class UserLogin(colander.MappingSchema): + username = colander.SchemaNode(colander.String()) -class CurrentPasswordCorrect(fe.validators.FancyValidator): - - def _to_python(self, value, state): - user = state - if not authenticate_user(Session, user.username, value): - raise fe.Invalid("The password is incorrect.", value, state) - return value - - -class ChangePassword(fe.Schema): - - allow_extra_fields = True - filter_extra_fields = True - - current_password = fe.All( - fe.validators.NotEmpty(), - CurrentPasswordCorrect()) - - new_password = fe.validators.NotEmpty() - confirm_password = fe.validators.NotEmpty() - - chained_validators = [fe.validators.FieldsMatch( - 'new_password', 'confirm_password')] + password = colander.SchemaNode(colander.String(), + widget=dfwidget.PasswordWidget()) class AuthenticationView(View): @@ -88,25 +61,29 @@ 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, mobile=False): + def login(self, **kwargs): """ The login view, responsible for displaying and handling the login form. """ - home = 'mobile.home' if mobile else 'home' - referrer = self.request.get_referrer(default=self.request.route_url(home)) + app = self.get_rattail_app() + referrer = self.request.get_referrer(default=self.request.route_url('home')) # redirect if already logged in if self.request.user: self.request.session.flash("{} is already logged in".format(self.request.user), 'error') return self.redirect(referrer) - form = forms.SimpleForm(self.request, UserLogin) + form = forms.Form(schema=UserLogin(), request=self.request) + form.save_label = "Login" + form.show_reset = True + form.show_cancel = False + form.button_icon_submit = 'user' if form.validate(): - user = self.authenticate_user(form.data['username'], - form.data['password']) + user = self.authenticate_user(form.validated['username'], + form.validated['password']) if user: # okay now they're truly logged in headers = login_user(self.request, user) @@ -117,39 +94,44 @@ 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'} return { - 'form': forms.FormRenderer(form), + 'form': form, 'referrer': referrer, - 'image_url': image_url, - 'dialog': mobile, + 'index_title': app.get_node_title(), + 'help_url': global_help_url(self.rattail_config), } 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 mobile_login(self): - return self.login(mobile=True) - - def logout(self, mobile=False): + def logout(self, **kwargs): """ View responsible for logging out the current user. This deletes/invalidates the current session and then redirects to the login page. """ + # truly logout the user headers = logout_user(self.request) + + # redirect to home page after login, if so configured if self.rattail_config.getbool('tailbone', 'home_after_logout', default=False): return self.redirect(self.request.route_url('home'), headers=headers) - login = 'mobile.login' if mobile else 'login' - referrer = self.request.get_referrer(default=self.request.route_url(login)) - return self.redirect(referrer, headers=headers) - def mobile_logout(self): - return self.logout(mobile=True) + # otherwise redirect to referrer, with 'login' page as fallback + referrer = self.request.get_referrer(default=self.request.route_url('login')) + return self.redirect(referrer, headers=headers) def noop(self): """ @@ -164,16 +146,40 @@ class AuthenticationView(View): if not self.request.user: return self.redirect(self.request.route_url('home')) - if self.rattail_config.demo() and self.request.user.username == 'chuck': - self.request.session.flash("Cannot change password for 'chuck' in demo mode", 'error') + 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()) - form = Form(self.request, schema=ChangePassword, state=self.request.user) + 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(): - set_user_password(self.request.user, form.data['new_password']) + 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': forms.FormRenderer(form)} + return {'index_title': str(self.request.user), + 'form': form} def become_root(self): """ @@ -199,6 +205,7 @@ class AuthenticationView(View): @classmethod def defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') # forbidden config.add_forbidden_view(cls, attr='forbidden') @@ -206,14 +213,10 @@ class AuthenticationView(View): # login config.add_route('login', '/login') config.add_view(cls, attr='login', route_name='login', renderer='/login.mako') - config.add_route('mobile.login', '/mobile/login') - config.add_view(cls, attr='mobile_login', route_name='mobile.login', renderer='/mobile/login.mako') # logout config.add_route('logout', '/logout') config.add_view(cls, attr='logout', route_name='logout') - config.add_route('mobile.logout', '/mobile/logout') - config.add_view(cls, attr='mobile_logout', route_name='mobile.logout') # no-op config.add_route('noop', '/noop') @@ -224,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/autocomplete.py b/tailbone/views/autocomplete.py deleted file mode 100644 index 96bbd36b..00000000 --- a/tailbone/views/autocomplete.py +++ /dev/null @@ -1,80 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -Autocomplete View -""" - -from __future__ import unicode_literals, absolute_import - -from tailbone.views.core import View -from tailbone.db import Session - - -class AutocompleteView(View): - """ - Base class for generic autocomplete views. - """ - - def prepare_term(self, term): - """ - If necessary, massage the incoming search term for use with the query. - """ - return term - - def filter_query(self, q): - return q - - def make_query(self, term): - q = Session.query(self.mapped_class) - q = self.filter_query(q) - q = q.filter(getattr(self.mapped_class, self.fieldname).ilike('%%%s%%' % term)) - q = q.order_by(getattr(self.mapped_class, self.fieldname)) - return q - - def query(self, term): - return self.make_query(term) - - def display(self, instance): - return getattr(instance, self.fieldname) - - def value(self, instance): - """ - Determine the data value for a query result instance. - """ - return instance.uuid - - def get_data(self, term): - return self.query(term).all() - - def __call__(self): - """ - View implementation. - """ - term = self.request.params.get(u'term') or u'' - term = term.strip() - if term: - term = self.prepare_term(term) - if not term: - return [] - results = self.get_data(term) - return [{'label': self.display(x), 'value': self.value(x)} for x in results] diff --git a/tailbone/views/batch/__init__.py b/tailbone/views/batch/__init__.py index 39f70fb3..e4b61802 100644 --- a/tailbone/views/batch/__init__.py +++ b/tailbone/views/batch/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2018 Lance Edgar # # This file is part of Rattail. # @@ -27,4 +27,3 @@ Views for batches from __future__ import unicode_literals, absolute_import from .core import BatchMasterView, FileBatchMasterView -from .core2 import BatchMasterView2, FileBatchMasterView2 diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 90b675d0..c162b579 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.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,33 +24,33 @@ Base views for maintaining "new-style" batches. """ -from __future__ import unicode_literals, absolute_import - import os +import sys +import json import datetime import logging -from cStringIO import StringIO +import socket +import subprocess +import tempfile +import warnings -import six +import json +import markdown import sqlalchemy as sa from sqlalchemy import orm -from rattail.db import model, Session as RattailSession from rattail.threads import Thread -from rattail.util import load_object, prettify +from rattail.util import simple_error -import formalchemy as fa -from pyramid import httpexceptions -from pyramid.renderers import render_to_response -from pyramid.response import FileResponse -from pyramid_simpleform import Form +import colander +from deform import widget as dfwidget 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.forms.renderers.batch import FileFieldRenderer -from tailbone.progress import SessionProgress log = logging.getLogger(__name__) @@ -61,214 +61,462 @@ class BatchMasterView(MasterView): Base class for all "batch master" views. """ default_handler_spec = None + 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 refresh_after_create = False - edit_with_rows = False cloneable = False executable = True - supports_mobile = True - mobile_filterable = True - mobile_rows_viewable = True + results_refreshable = False + results_executable = False has_worksheet = False + has_worksheet_file = False + delete_requires_progress = True + + input_file_template_config_section = 'rattail.batch' + + grid_columns = [ + 'id', + 'description', + 'created', + 'created_by', + 'rowcount', + # 'status_code', + # 'complete', + 'executed', + 'executed_by', + ] + + form_fields = [ + 'id', + 'description', + 'notes', + 'params', + 'rowcount', + 'status_code', + 'created', + 'created_by', + 'executed', + 'executed_by', + ] + + row_labels = { + 'upc': "UPC", + 'item_id': "Item ID", + 'status_code': "Status", + } 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 - def get_handler(self): + @classmethod + def get_handler_factory(cls, rattail_config): """ - Returns a `BatchHandler` instance for the view. All (?) custom batch - views should define a default handler class; however this may in all - (?) cases be overridden by config also. The specific setting required - to do so will depend on the 'key' for the type of batch involved, e.g. - assuming the 'vendor_catalog' batch: + Returns the "factory" (class) which will be used to create the batch + handler. All (?) custom batch views should define a default handler + class; however this may in all (?) cases be overridden by config also. + The specific setting required to do so will depend on the 'key' for the + type of batch involved, e.g. assuming the 'inventory' batch: .. code-block:: ini [rattail.batch] - vendor_catalog.handler = myapp.batch.vendorcatalog:CustomCatalogHandler + inventory.handler = poser.batch.inventory:InventoryBatchHandler Note that the 'key' for a batch is generally the same as its primary table name, although technically it is whatever value returns from the ``batch_key`` attribute of the main batch model class. """ - key = self.model_class.batch_key - spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key), - default=self.default_handler_spec) - if spec: - return load_object(spec)(self.rattail_config) - return self.batch_handler_class(self.rattail_config) + # 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 + 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 + + def get_handler(self): + """ + Returns a batch handler instance to be used by the view. Note that + this will use the factory provided by :meth:`get_handler_factory()` to + create the handler instance. + """ + 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): + kwargs = super().template_kwargs_view(**kwargs) batch = kwargs['instance'] kwargs['batch'] = batch kwargs['handler'] = self.handler + + if self.has_worksheet_file and self.allow_worksheet(batch) and self.has_perm('worksheet'): + kwargs['upload_worksheet_form'] = self.make_upload_worksheet_form(batch) + kwargs['execute_title'] = self.get_execute_title(batch) kwargs['execute_enabled'] = self.instance_executable(batch) - if kwargs['execute_enabled'] and self.has_execution_options(batch): - kwargs['rendered_execution_options'] = self.render_execution_options(batch) + if kwargs['execute_enabled']: + url = self.get_action_url('execute', batch) + kwargs['execute_form'] = self.make_execute_form(batch, action_url=url) + description = (self.handler.describe_execution(batch) + or "TODO: handler does not provide a description for this batch") + kwargs['execution_described'] = markdown.markdown( + description, extensions=['fenced_code', 'codehilite']) + else: + kwargs['why_not_execute'] = self.handler.why_not_execute(batch) + + 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) + form = forms.Form(schema=UploadWorksheet(), + request=self.request, + action_url=action_url, + component='upload-worksheet-form') + form.set_type('worksheet_file', 'file') + # TODO: must set these to avoid some default code + form.auto_disable = False + form.auto_disable_save = False + return form + + def download_worksheet(self): + batch = self.get_instance() + path = self.handler.write_worksheet(batch) + root, ext = os.path.splitext(path) + # we present a more descriptive filename for download + filename = '{}.worksheet.{}{}'.format(batch.batch_key, batch.id_str, ext) + return self.file_response(path, filename=filename) + + def upload_worksheet(self): + batch = self.get_instance() + form = self.make_upload_worksheet_form(batch) + if self.validate_form(form): + uploads = self.normalize_uploads(form) + path = uploads['worksheet_file']['temp_path'] + return self.handler_action(batch, 'update_from_worksheet', path=path) + self.request.session.flash("Upload form did not validate!", 'error') + return self.redirect(self.get_action_url('view', batch)) + + def update_from_worksheet_thread(self, batch_uuid, user_uuid, progress, path=None): + """ + Thread target for updating a batch from worksheet. + """ + session = self.make_isolated_session() + batch = session.get(self.model_class, batch_uuid) + try: + self.handler.update_from_worksheet(batch, path, progress=progress) + + except Exception as error: + session.rollback() + log.exception("upload/update failed for '{}' batch: {}".format(self.batch_key, batch)) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Upload processing failed: {}".format( + simple_error(error)) + progress.session.save() + + else: + session.commit() + success_msg = "Batch has been updated: {}".format(batch) + success_url = self.get_action_url('view', batch) + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_msg'] = success_msg + progress.session['success_url'] = success_url + progress.session.save() + + def make_status_breakdown(self, batch, rows=None, status_enum=None): + """ + Returns a simple list of 2-tuples, each of which has the status display + title as first member, and number of rows with that status as second + member. + """ + breakdown = {} + if rows is None: + rows = batch.active_rows() + for row in rows: + if row.status_code is not None: + if row.status_code not in breakdown: + status = status_enum or row.STATUS + breakdown[row.status_code] = { + 'code': row.status_code, + 'title': status[row.status_code], + 'count': 0, + } + breakdown[row.status_code]['count'] += 1 + return list(breakdown.values()) + def allow_worksheet(self, batch): return not batch.executed and not batch.complete + def configure_grid(self, g): + super().configure_grid(g) + model = self.model + + # created_by + CreatedBy = orm.aliased(model.User) + g.set_joiner('created_by', lambda q: q.join(CreatedBy, + CreatedBy.uuid == self.model_class.created_by_uuid)) + g.set_sorter('created_by', CreatedBy.username) + g.set_filter('created_by', CreatedBy.username) + g.set_label('created_by', "Created by") + + # executed + g.filters['executed'].default_active = True + g.filters['executed'].default_verb = 'is_null' + + # executed_by + ExecutedBy = orm.aliased(model.User) + g.set_joiner('executed_by', lambda q: q.outerjoin(ExecutedBy, + ExecutedBy.uuid == self.model_class.executed_by_uuid)) + g.set_sorter('executed_by', ExecutedBy.username) + g.set_filter('executed_by', ExecutedBy.username) + g.set_label('executed_by', "Executed by") + + g.set_sort_defaults('id', 'desc') + + g.set_enum('status_code', self.model_class.STATUS) + + g.set_renderer('id', self.render_id_str) + + g.set_link('id') + g.set_link('description') + g.set_link('executed') + + g.set_label('id', "Batch ID") + g.set_label('rowcount', "Rows") + g.set_label('status_code', "Status") + def template_kwargs_index(self, **kwargs): - kwargs['execute_enabled'] = self.instance_executable(None) - if kwargs['execute_enabled'] and self.has_execution_options(): - kwargs['rendered_execution_options'] = self.render_execution_options() - return kwargs - - def render_execution_options(self, batch=None): - if batch is None: - batch = self.model_class - form = self.make_execution_options_form(batch) - kwargs = { - 'batch': batch, - 'form': forms.FormRenderer(form), - } - kwargs = self.get_exec_options_kwargs(**kwargs) - return self.render('exec_options', kwargs) - - def get_exec_options_kwargs(self, **kwargs): + route_prefix = self.get_route_prefix() + if self.results_executable: + url = self.request.route_url('{}.execute_results'.format(route_prefix)) + kwargs['execute_form'] = self.make_execute_form(action_url=url) return kwargs def get_instance_title(self, batch): - return batch.id_str or unicode(batch) + if batch.description: + return "{} {}".format(batch.id_str, batch.description) + return batch.id_str - def get_mobile_data(self, session=None): - return super(BatchMasterView, self).get_mobile_data(session=session)\ - .order_by(self.model_class.id.desc()) + def configure_form(self, f): + super().configure_form(f) - def make_mobile_filters(self): - """ - Returns a set of filters for the mobile grid. - """ - filters = grids.filters.GridFilterSet() - filters['status'] = MobileBatchStatusFilter(self.model_class, 'status', default_value='pending') - return filters + # id + f.set_readonly('id') + f.set_renderer('id', self.render_id_str) + f.set_label('id', "Batch ID") - def _preconfigure_fieldset(self, fs): - """ - Apply some commonly-useful pre-configuration to the main batch - fieldset. - """ - fs.id.set(label="Batch ID", readonly=True, renderer=forms.renderers.BatchIDFieldRenderer) - - fs.created.set(readonly=True) - fs.created_by.set(label="Created by", renderer=forms.renderers.UserFieldRenderer, - readonly=True) - fs.cognized_by.set(label="Cognized by", renderer=forms.renderers.UserFieldRenderer) - fs.rowcount.set(label="Row Count", readonly=True) - fs.status_code.set(label="Status", renderer=StatusRenderer(self.model_class.STATUS)) - fs.executed.set(readonly=True) - fs.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer, readonly=True) - fs.notes.set(renderer=fa.TextAreaFieldRenderer, size=(80, 10)) - - if self.creating and self.request.user: - batch = fs.model - batch.created_by_uuid = self.request.user.uuid - - def configure_fieldset(self, fs): - """ - Apply final configuration to the main batch fieldset. Custom batch - views are encouraged to override this method. - """ + # params if self.creating: - fs.configure() - + f.remove('params') else: - batch = fs.model - if batch.executed: - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - fs.executed, - fs.executed_by, - ]) - else: - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - ]) + f.set_readonly('params') + f.set_renderer('params', self.render_params) - def _postconfigure_fieldset(self, fs): + # created + f.set_readonly('created') + f.set_readonly('created_by') + f.set_renderer('created_by', self.render_user) + f.set_label('created_by', "Created by") + + # cognized + f.set_renderer('cognized_by', self.render_user) + f.set_label('cognized_by', "Cognized by") + + # row count + f.set_readonly('rowcount') + f.set_label('rowcount', "Row Count") + + # status_code if self.creating: - unwanted = [ - 'id', - 'rowcount', - 'created', - 'created_by', - 'cognized', - 'cognized_by', - 'executed', - 'executed_by', - 'purge', - 'data_rows', - ] - for field in unwanted: - if field in fs.render_fields: - delattr(fs, field) + f.remove_field('status_code') else: - batch = fs.model + f.set_readonly('status_code') + f.set_renderer('status_code', self.make_status_renderer(self.model_class.STATUS)) + f.set_label('status_code', "Status") + + # complete + if self.viewing: + f.set_renderer('complete', self.render_complete) + + # executed + f.set_readonly('executed') + f.set_readonly('executed_by') + f.set_renderer('executed_by', self.render_user) + f.set_label('executed_by', "Executed by") + + # notes + f.set_type('notes', 'text_wrapped') + + # if self.creating and self.request.user: + # batch = fs.model + # batch.created_by_uuid = self.request.user.uuid + + if self.creating: + f.remove_fields('id', + 'rowcount', + 'created', + 'created_by', + 'cognized', + 'cognized_by', + 'executed', + 'executed_by', + 'purge') + + else: # not creating + batch = self.get_instance() if not batch.executed: - unwanted = [ - 'executed', - 'executed_by', - ] - for field in unwanted: - if field in fs.render_fields: - delattr(fs, field) + f.remove_fields('executed', + 'executed_by') - def add_file_field(self, fs, name, **kwargs): - kwargs.setdefault('value', lambda b: getattr(b, 'filename_{}'.format(name))) - if 'renderer' not in kwargs: - batch = fs.model - storage_path = self.rattail_config.batch_filedir(self.handler.batch_key) - download_url = self.get_action_url('download', batch, _query={'file': name}) - kwargs['renderer'] = FileFieldRenderer.new(self, - storage_path=storage_path, - download_url=download_url) - fs.append(fa.Field(name, **kwargs)) + 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): + text = "Yes" if batch.complete else "No" + + if batch.executed or not self.has_perm('edit'): + return text + + if batch.complete: + label = "Mark Incomplete" + value = 'false' + else: + label = "Mark Complete" + value = 'true' + + url = self.get_action_url('toggle_complete', batch) + kwargs = {'@submit': 'togglingBatchComplete = true'} + begin_form = tags.form(url, **kwargs) + + 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, + render_csrf_token(self.request), + tags.hidden('complete', value=value), + submit, + tags.end_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) + + def render_user(self, batch, field): + user = getattr(batch, field) + if not user: + return "" + title = str(user) + url = self.request.route_url('users.view', uuid=user.uuid) + return tags.link_to(title, url) def save_create_form(self, form): + uploads = self.normalize_uploads(form) self.before_create(form) - with Session.no_autoflush: + session = self.Session() + with session.no_autoflush: # transfer form data to batch instance - form.fieldset.sync() - batch = form.fieldset.model + batch = self.objectify(form, self.form_deserialized) # current user is batch creator batch.created_by = self.request.user or self.late_login_user() - # destroy initial batch and re-make using handler + # obtain kwargs for making batch via handler, below kwargs = self.get_batch_kwargs(batch) - Session.expunge(batch) - batch = self.handler.make_batch(Session(), **kwargs) - Session.flush() + # 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'], str): + kwargs['filename'] = '' # null not allowed - # TODO: this needs work yet surely... - # if batch has input data file, let handler properly establish that - filename = getattr(batch, 'filename', None) - if filename: - path = os.path.join(self.upload_dir, filename) - if os.path.exists(path): - self.handler.set_input_file(batch, path) - os.remove(path) + # TODO: is this still necessary with colander? + # destroy initial batch and re-make using handler + # if batch in self.Session: + # self.Session.expunge(batch) + batch = self.handler.make_batch(session, **kwargs) - # return this object to replace the original + self.Session.flush() + self.process_uploads(batch, form, uploads) return batch - def get_batch_kwargs(self, batch, mobile=False): + def process_uploads(self, batch, form, 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 the given batch as a template. @@ -299,77 +547,94 @@ class BatchMasterView(MasterView): """ return True - def redirect_after_create(self, batch, mobile=False): + def redirect_after_create(self, batch, **kwargs): if self.handler.should_populate(batch): - return self.redirect(self.get_action_url('prefill', batch, mobile=mobile)) + return self.redirect(self.get_action_url('prefill', batch)) elif self.refresh_after_create: - return self.redirect(self.get_action_url('refresh', batch, mobile=mobile)) + return self.redirect(self.get_action_url('refresh', batch)) else: - return self.redirect(self.get_action_url('view', batch, mobile=mobile)) - - # TODO: some of this at least can go to master now right? - def edit(self): - """ - Don't allow editing a batch which has already been executed. - """ - self.editing = True - batch = self.get_instance() - if not self.editable_instance(batch): return self.redirect(self.get_action_url('view', batch)) - if self.edit_with_rows: - grid = self.make_row_grid(batch=batch) + def template_kwargs_edit(self, **kwargs): + batch = kwargs['instance'] + kwargs['batch'] = batch + return kwargs - # 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.params.get('partial'): - self.request.response.content_type = b'text/html' - self.request.response.text = grid.render_grid() - return self.request.response - - form = self.make_form(batch) - if self.request.method == 'POST': + def toggle_complete(self): + batch = self.get_instance() + if batch.executed: + self.request.session.flash("Request ignored, since batch has already been executed") + else: + form = forms.Form(schema=ToggleComplete(), request=self.request) if form.validate(): - self.save_edit_form(form) - self.request.session.flash("{} has been updated: {}".format( - self.get_model_title(), self.get_instance_title(batch))) - return self.redirect_after_edit(batch) + if form.validated['complete']: + self.mark_batch_complete(batch) + else: + self.mark_batch_incomplete(batch) + return self.redirect(self.get_action_url('view', batch)) - context = { - 'instance': batch, - 'instance_title': self.get_instance_title(batch), - 'instance_deletable': self.deletable_instance(batch), - 'form': form, - 'batch': batch, - 'execute_title': self.get_execute_title(batch), - 'execute_enabled': self.instance_executable(batch), - } + def mark_batch_complete(self, batch): + self.handler.mark_complete(batch) - if self.edit_with_rows: - context['rows_grid'] = grid - if context['execute_enabled'] and self.has_execution_options(batch): - context['rendered_execution_options'] = self.render_execution_options(batch) - - return self.render_to_response('edit', context) - - def mobile_mark_complete(self): - batch = self.get_instance() - batch.complete = True - return self.redirect(self.get_index_url(mobile=True)) - - def mobile_mark_pending(self): - batch = self.get_instance() - batch.complete = False - return self.redirect(self.get_action_url('view', batch, mobile=True)) + def mark_batch_incomplete(self, batch): + self.handler.mark_incomplete(batch) def rows_creatable_for(self, batch): """ - Only allow creating new rows on a batch if it hasn't yet been executed. + Only allow creating new rows on a batch if it hasn't yet been executed + or marked complete. """ - return not batch.executed + if batch.executed: + return False + if batch.complete: + return False + return True + + def rows_quickable_for(self, batch): + """ + Must return boolean indicating whether the "quick row" feature should + be allowed for the given batch. By default, returns ``False`` if batch + has already been executed or marked complete, and ``True`` otherwise. + """ + if batch.executed: + return False + if batch.complete: + return False + return True + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.set_sort_defaults('sequence') + g.set_link('sequence') + g.set_label('sequence', "Seq.") + if 'sequence' in g.filters: + g.filters['sequence'].label = "Sequence" + + if 'status_code' in g.filters: + g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) + + if self.model_row_class: + g.set_enum('status_code', self.model_row_class.STATUS) + + g.set_renderer('status_code', self.render_row_status) + + def get_row_status_enum(self): + return self.model_row_class.STATUS + + 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, str(code)) + if row.status_text: + return HTML.tag('span', title=row.status_text, c=text) + return text def create_row(self): """ @@ -379,71 +644,149 @@ 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 mobile_create_row(self): - """ - Only allow creating a new row if the batch hasn't yet been executed. - """ + def save_create_row_form(self, form): batch = self.get_instance() - 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, mobile=True)) - return super(BatchMasterView, self).mobile_create_row() - - def before_create_row(self, form): - batch = self.get_instance() - row = form.fieldset.model + row = self.objectify(form, self.form_deserialized) self.handler.add_row(batch, row) + self.Session.flush() + return row def after_create_row(self, row): self.handler.refresh_row(row) + def configure_row_form(self, 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) + f.set_renderer('status_code', self.render_row_status) + f.set_readonly('status_code') + f.set_label('status_code', "Status") + + # status text + f.set_readonly('status_text') + def make_default_row_grid_tools(self, batch): - if self.rows_creatable and not batch.executed: + 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): + """ + Whether or not rows may be edited or deleted will depend partially on + whether the parent batch has been executed. + """ + batch = self.get_instance() + + # TODO: most of this logic is copied from MasterView, should refactor/merge somehow... + if 'actions' not in kwargs: + actions = [] + + # view action + if self.rows_viewable: + view = lambda r, i: self.get_row_action_url('view', r) + actions.append(self.make_action('view', icon='eye', url=view)) + + # edit and delete are NOT allowed if batch is "complete" + if not batch.complete: + + # edit action + 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['actions'] = actions + + 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 '') - def get_mobile_row_data(self, batch): - return super(BatchMasterView, self).get_mobile_row_data(batch)\ - .order_by(self.model_row_class.sequence) - def redirect_after_edit(self, batch): """ If refresh flag is set, do that; otherwise go (back) to view/edit page. """ if self.request.params.get('refresh') == 'true': return self.redirect(self.get_action_url('refresh', batch)) - if self.edit_with_rows: - return self.redirect(self.get_action_url('edit', batch)) return self.redirect(self.get_action_url('view', batch)) def delete_instance(self, batch): """ Delete all data (files etc.) for the batch. """ - if hasattr(batch, 'delete_data'): - batch.delete_data(self.rattail_config) - del batch.data_rows[:] - 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 get_fallback_templates(self, template, mobile=False): - if mobile: - return [ - '/mobile/batch/{}.mako'.format(template), - '/mobile/master/{}.mako'.format(template), - ] + 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 [ '/batch/{}.mako'.format(template), '/master/{}.mako'.format(template), @@ -480,25 +823,222 @@ class BatchMasterView(MasterView): # TODO execution_options_schema = None - def make_execution_options_form(self, batch=None): + def make_execute_schema(self, batch): + return self.execution_options_schema().bind(batch=batch) + + def make_execute_form(self, batch=None, **kwargs): """ Return a proper Form for execution options. """ - if batch is None: - batch = self.model_class defaults = {} - for field in self.execution_options_schema.fields: - key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field) - value = self.request.session.get(key) - if value: - defaults[field] = value - return Form(self.request, schema=self.execution_options_schema, - defaults=defaults or None) + route_prefix = self.get_route_prefix() + + schema = None + if self.has_execution_options(batch): + if batch is None: + batch = self.model_class + schema = self.make_execute_schema(batch) + 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] + + # make sure field label is preserved + if field.title: + labels = kwargs.setdefault('labels', {}) + labels[field.name] = field.title + + # auto-convert select widgets for theme + if isinstance(field.widget, forms.widgets.PlainSelectWidget): + warnings.warn("PlainSelectWidget is deprecated; " + "please use deform.widget.SelectWidget instead", + DeprecationWarning, stacklevel=2) + field.widget = dfwidget.SelectWidget(values=field.widget.values) + + if not schema: + schema = colander.Schema() + + kwargs['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'): return self.handler.get_execute_title(batch) - return "Execute this batch" + return "Execute Batch" + + def handler_action(self, batch, batch_action, **kwargs): + """ + View which will attempt to refresh all data for the batch. What + exactly this means will depend on the type of batch etc. + """ + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + user = self.request.user + user_uuid = user.uuid if user else None + username = user.username if user else None + + key = '{}.{}'.format(self.model_class.__tablename__, batch_action) + progress = self.make_progress(key) + + # must ensure versioning is *disabled* during action, if handler says so + allow_versioning = self.handler.allow_versioning(batch_action) + if not allow_versioning and self.rattail_config.versioning_enabled(): + can_cancel = False + + # 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.get_action_url('view', batch) + thread = Thread(target=self.progress_thread, args=(sock, success_url, progress)) + thread.start() + + # launch thread to invoke handler action + thread = Thread(target=self.action_subprocess_thread, + args=((batch.uuid,), port, username, batch_action, progress), + kwargs=kwargs) + thread.start() + + else: # either versioning is disabled, or handler doesn't mind + can_cancel = True + + # 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.start() + + return self.render_progress(progress, { + 'can_cancel': can_cancel, + 'cancel_url': self.get_action_url('view', batch), + 'cancel_msg': "{} of batch was canceled.".format(batch_action.capitalize()), + }) + + def launch_subprocess(self, port=None, username=None, + command='rattail', command_args=None, + subcommand=None, subcommand_args=None): + + # construct command + 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.prioritized_files: + cmd.extend(['--config', path]) + if username: + cmd.extend(['--runas', username]) + if command_args: + cmd.extend(command_args) + cmd.extend([ + '--progress', + '--progress-socket', '127.0.0.1:{}'.format(port), + subcommand, + ]) + if subcommand_args: + cmd.extend(subcommand_args) + + # run command in subprocess + log.debug("launching command in subprocess: %s", 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, 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 + the handler says versioning must be avoided during the action. It must + 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, + batch_uuid, + ] + if handler_action == 'execute' and kwargs: + subargs.extend([ + '--kwargs', + json.dumps(kwargs), + ]) + + # invoke command to act on batch in separate process + try: + self.launch_subprocess(port=port, username=username, + command='rattail', + command_args=[ + '--no-versioning', + ], + 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) + + # TODO: write minimal error info to socket + if progress: + 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, error) + progress.session.save() + + return + + models = getattr(self.handler, 'version_catchup_{}'.format(handler_action), None) + if models: + self.catchup_versions(port, batch_uuid, username, *models) + + 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 catchup_versions(self, port, batch_uuid, username, *models): + 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 = str(batch) + + self.launch_subprocess( + port=port, username=username, + command='rattail', + subcommand='import-versions', + subcommand_args=[ + '--comment', + "version catch-up for '{}' batch {}: {}".format(self.handler.batch_key, batch_id, description), + ] + list(models)) def prefill(self): """ @@ -506,45 +1046,30 @@ class BatchMasterView(MasterView): exactly this means will depend on the type of batch etc. """ batch = self.get_instance() - route_prefix = self.get_route_prefix() - permission_prefix = self.get_permission_prefix() + return self.handler_action(batch, 'populate') - # showing progress requires a separate thread; start that first - key = '{}.prefill'.format(route_prefix) - progress = SessionProgress(self.request, key) - thread = Thread(target=self.prefill_thread, args=(batch.uuid, progress)) - thread.start() - - # Send user to progress page. - kwargs = { - 'cancel_url': self.get_action_url('view', batch), - 'cancel_msg': "Batch prefill was canceled.", - } - - # TODO: This seems hacky...it exists for (only) one specific scenario. - if not self.request.has_perm('{}.view'.format(permission_prefix)): - kwargs['cancel_url'] = self.request.route_url('{}.create'.format(route_prefix)) - - return self.render_progress(progress, kwargs) - - def prefill_thread(self, batch_uuid, progress): + def populate_thread(self, batch_uuid, user_uuid, progress, **kwargs): """ - Thread target for prefilling batch data with progress indicator. + 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) + session = app.make_session() + batch = session.get(self.model_class, batch_uuid) + user = session.get(model.User, user_uuid) try: - self.handler.populate(batch, progress=progress) - self.handler.refresh_batch_status(batch) + self.handler.do_populate(batch, user, progress=progress) + session.flush() except Exception as error: session.rollback() - log.warning("batch pre-fill failed: {}".format(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 pre-fill failed: {} {}".format(error.__class__.__name__, error) + progress.session['error_msg'] = simple_error(error) progress.session.save() return @@ -565,56 +1090,9 @@ class BatchMasterView(MasterView): exactly this means will depend on the type of batch etc. """ batch = self.get_instance() - route_prefix = self.get_route_prefix() - permission_prefix = self.get_permission_prefix() + return self.handler_action(batch, 'refresh') - # TODO: deprecate / remove this - cognizer = self.request.user - if not cognizer: - uuid = self.request.session.pop('late_login_user', None) - cognizer = Session.query(model.User).get(uuid) if uuid else None - - # TODO: refresh should probably always imply/use progress - # If handler doesn't declare the need for progress indicator, things - # are nice and simple. - if not getattr(self.handler, 'show_progress', True): - self.refresh_data(Session, batch, cognizer=cognizer) - self.request.session.flash("Batch data has been refreshed.") - - # TODO: This seems hacky...it exists for (only) one specific scenario. - if not self.request.has_perm('{}.view'.format(permission_prefix)): - return self.redirect(self.request.route_url('{}.create'.format(route_prefix))) - - return self.redirect(self.get_action_url('view', batch)) - - # Showing progress requires a separate thread; start that first. - key = '{}.refresh'.format(self.model_class.__tablename__) - progress = SessionProgress(self.request, key) - # success_url = self.request.route_url('vendors.scangenius.create') if not self.request.user else None - - # TODO: This seems hacky...it exists for (only) one specific scenario. - success_url = None - if not self.request.user: - success_url = self.request.route_url('{}.create'.format(route_prefix)) - - thread = Thread(target=self.refresh_thread, args=(batch.uuid, progress, - cognizer.uuid if cognizer else None, - success_url)) - thread.start() - - # Send user to progress page. - kwargs = { - 'cancel_url': self.get_action_url('view', batch), - 'cancel_msg': "Batch refresh was canceled.", - } - - # TODO: This seems hacky...it exists for (only) one specific scenario. - if not self.request.has_perm('{}.view'.format(permission_prefix)): - kwargs['cancel_url'] = self.request.route_url('{}.create'.format(route_prefix)) - - return self.render_progress(progress, kwargs) - - def refresh_data(self, session, batch, cognizer=None, progress=None): + def refresh_data(self, session, batch, user, progress=None): """ Instruct the batch handler to refresh all data for the batch. """ @@ -625,20 +1103,24 @@ class BatchMasterView(MasterView): batch.cognized_by = cognizer or session.merge(self.request.user) else: # the future - self.handler.refresh(batch, progress=progress) + user = user or session.merge(self.request.user) + self.handler.do_refresh(batch, user, progress=progress) - def refresh_thread(self, batch_uuid, progress=None, cognizer_uuid=None, success_url=None): + def refresh_thread(self, batch_uuid, user_uuid, progress, **kwargs): """ Thread target for refreshing batch data with progress indicator. """ # 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(cognizer_uuid) if cognizer_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=cognizer, progress=progress) + 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) @@ -646,7 +1128,8 @@ class BatchMasterView(MasterView): if progress: progress.session.load() progress.session['error'] = True - progress.session['error_msg'] = "Data refresh failed: {} {}".format(error.__class__.__name__, error) + progress.session['error_msg'] = "Data refresh failed: {}".format( + simple_error(error)) progress.session.save() return @@ -658,9 +1141,69 @@ class BatchMasterView(MasterView): if progress: progress.session.load() progress.session['complete'] = True - progress.session['success_url'] = success_url or self.get_action_url('view', batch) + progress.session['success_url'] = self.get_action_url('view', batch) progress.session.save() + def refresh_results(self): + """ + Refresh all batches which are returned from the current index query. + Starts a separate thread for the refresh, and displays a progress + indicator page. + """ + key = '{}.refresh_results'.format(self.get_route_prefix()) + batches = self.get_effective_data() + progress = self.make_progress(key) + kwargs = {'progress': progress} + thread = Thread(target=self.refresh_results_thread, + args=(batches, self.request.user.uuid), + kwargs=kwargs) + thread.start() + + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Batch execution was canceled", + }) + + def refresh_results_thread(self, batches, user_uuid, progress=None): + """ + Thread target for refreshing multiple batches with progress indicator. + """ + app = self.get_rattail_app() + model = self.model + session = app.make_session() + batches = batches.with_session(session).all() + user = session.get(model.User, user_uuid) + try: + self.handler.refresh_many(batches, user=user, progress=progress) + + except Exception as error: + session.rollback() + log.exception("refresh failed for batch(es)!") + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = self.refresh_error_message(error) + progress.session.save() + + else: + session.commit() + self.request.session.flash("{} {} were refreshed".format( + len(batches), self.get_model_title_plural())) + success_url = self.get_refresh_results_success_url() + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = success_url + progress.session.save() + + def refresh_error_message(self, error): + return "Batch refresh failed: {}".format(simple_error(error)) + + def get_refresh_results_success_url(self): + return self.get_index_url() + ######################################## # batch rows ######################################## @@ -683,62 +1226,66 @@ class BatchMasterView(MasterView): def row_editable(self, row): """ - Batch rows are editable only until batch has been executed. + Batch rows are editable only until batch is complete or executed. """ - batch = row.batch - return self.rows_editable and not batch.executed and not batch.complete + if not (self.rows_editable or self.rows_editable_but_not_directly): + return False + + batch = self.get_parent(row) + if batch.complete or batch.executed: + return False + + return True def row_deletable(self, row): """ - Batch rows are deletable only until batch has been executed. + Batch rows are deletable only until batch is complete or executed. """ - return self.rows_deletable and not row.batch.executed + if not self.rows_deletable: + return False - def _preconfigure_row_fieldset(self, fs): - fs.sequence.set(readonly=True) - fs.status_code.set(renderer=StatusRenderer(self.model_row_class.STATUS), - label="Status", readonly=True) - fs.status_text.set(readonly=True) - fs.removed.set(readonly=True) - try: - fs.product.set(readonly=True, renderer=forms.renderers.ProductFieldRenderer) - except AttributeError: - pass + batch = self.get_parent(row) - def configure_row_fieldset(self, fs): - fs.configure() - del fs.batch + 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'] + # TODO: should these be set somewhere else? + kwargs['row'] = kwargs['instance'] + kwargs['batch'] = kwargs['row'].batch return kwargs def get_parent(self, row): return row.batch - def delete_row(self): + def delete_row_object(self, row): """ - "Delete" a row from the batch. This sets the ``removed`` flag on the - row but does not truly delete it. + Perform the actual deletion of given row object. """ - row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid']) - if not row: - raise httpexceptions.HTTPNotFound() - row.removed = True - batch = row.batch - self.handler.refresh_batch_status(batch) - if batch.rowcount is not None: - batch.rowcount -= 1 - return self.redirect(self.get_action_url('view', self.get_parent(row))) + 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. - """ - query = self.get_effective_row_data(sort=False) - query.update({'removed': True}, synchronize_session=False) - return self.redirect(self.get_action_url('view', self.get_instance())) + def delete_row_objects(self, rows): + deleted = super().delete_row_objects(rows) + batch = self.get_instance() + + # decrement rowcount for batch + if batch.rowcount is not None: + batch.rowcount -= deleted + + # refresh batch status + self.Session.refresh(batch) + self.handler.refresh_batch_status(batch) + + return deleted def execute(self): """ @@ -746,46 +1293,37 @@ class BatchMasterView(MasterView): displays a progress indicator page. """ batch = self.get_instance() - if self.request.method == 'POST': + self.executing = True + form = self.make_execute_form(batch) + if form.validate(): + kwargs = dict(form.validated) - kwargs = {} - if self.has_execution_options(batch): - form = self.make_execution_options_form(batch) - assert form.validate() # TODO - kwargs.update(form.data) - for key, value in form.data.iteritems(): - # TODO: properly serialize option values? - self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = unicode(value) + # cache options to use as defaults next time + for key, value in form.validated.items(): + self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = value - key = '{}.execute'.format(self.model_class.__tablename__) - progress = SessionProgress(self.request, key) - kwargs['progress'] = progress - thread = Thread(target=self.execute_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs) - thread.start() + return self.handler_action(batch, 'execute', **kwargs) - return self.render_progress(progress, { - 'cancel_url': self.get_action_url('view', batch), - 'cancel_msg': "Batch execution was canceled.", - }) - - self.request.session.flash("Sorry, you must POST to execute a batch.", 'error') + self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error') return self.redirect(self.get_action_url('view', batch)) def execute_error_message(self, error): - return "Batch execution failed: {}: {}".format(type(error).__name__, error) + return "Batch execution failed: {}".format(simple_error(error)) - def execute_thread(self, batch_uuid, user_uuid, progress=None, **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.execute(batch, user=user, progress=progress, **kwargs) + result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs) # If anything goes wrong, rollback and log the error etc. except Exception as error: @@ -800,13 +1338,11 @@ class BatchMasterView(MasterView): # If no error, check result flag (false means user canceled). else: + success_msg = None if result: - batch.executed = datetime.datetime.utcnow() - batch.executed_by = user session.commit() - # TODO: this doesn't always work...? - self.request.session.flash("{} has been executed: {}".format( - self.get_model_title(), batch.id_str)) + success_msg = "{} has been executed: {}".format( + self.get_model_title(), batch.id_str) else: session.rollback() @@ -818,6 +1354,8 @@ class BatchMasterView(MasterView): 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 get_execute_success_url(self, batch, result, **kwargs): @@ -829,22 +1367,17 @@ class BatchMasterView(MasterView): Starts a separate thread for the execution, and displays a progress indicator page. """ - if self.request.method == 'POST': + form = self.make_execute_form() + if form.validate(): + kwargs = dict(form.validated) - kwargs = {} - if self.has_execution_options(): - form = self.make_execution_options_form() - if not form.validate(): - raise RuntimeError("Execution options form did not validate") - kwargs.update(form.data) - - # cache options to use as defaults next time - for key, value in form.data.iteritems(): - self.request.session['batch.{}.execute_option.{}'.format(self.model_class.batch_key, key)] = six.text_type(value) + # cache options to use as defaults next time + for key, value in form.validated.items(): + self.request.session['batch.{}.execute_option.{}'.format(self.model_class.batch_key, key)] = value key = '{}.execute_results'.format(self.model_class.__tablename__) batches = self.get_effective_data() - progress = SessionProgress(self.request, key) + progress = self.make_progress(key) kwargs['progress'] = progress thread = Thread(target=self.execute_results_thread, args=(batches, self.request.user.uuid), kwargs=kwargs) thread.start() @@ -854,16 +1387,18 @@ class BatchMasterView(MasterView): 'cancel_msg': "Batch execution was canceled", }) - self.request.session.flash("Sorry, you must POST to execute batches", 'error') + self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error') return self.redirect(self.get_index_url()) def execute_results_thread(self, batches, user_uuid, progress=None, **kwargs): """ 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) @@ -881,10 +1416,6 @@ class BatchMasterView(MasterView): # If no error, check result flag (false means user canceled). else: if result: - now = datetime.datetime.utcnow() - for batch in batches: - batch.executed = now - batch.executed_by = user session.commit() # TODO: this doesn't always work...? self.request.session.flash("{} {} were executed".format( @@ -905,14 +1436,17 @@ 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 != 'removed' and not field.endswith('uuid')] + if field not in ('uuid', 'batch_uuid', 'removed')] return fields def get_row_results_csv_filename(self, batch): return '{}.{}.csv'.format(self.get_route_prefix(), batch.id_str) + def get_row_results_xlsx_filename(self, batch): + return '{}.{}.xlsx'.format(self.get_route_prefix(), batch.id_str) + def clone(self): """ Clone current batch as new batch @@ -928,9 +1462,11 @@ class BatchMasterView(MasterView): @classmethod def _batch_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') model_key = cls.get_model_key() 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() @@ -939,59 +1475,79 @@ class BatchMasterView(MasterView): # else the perm group label will not display correctly... config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) - # prefill row data + # populate row data config.add_route('{}.prefill'.format(route_prefix), '{}/{{uuid}}/prefill'.format(url_prefix)) config.add_view(cls, attr='prefill', route_name='{}.prefill'.format(route_prefix), permission='{}.create'.format(permission_prefix)) # worksheet - if cls.has_worksheet: + if cls.has_worksheet or cls.has_worksheet_file: config.add_tailbone_permission(permission_prefix, '{}.worksheet'.format(permission_prefix), "Edit {} data as worksheet".format(model_title)) + if cls.has_worksheet: config.add_route('{}.worksheet'.format(route_prefix), '{}/{{{}}}/worksheet'.format(url_prefix, model_key)) config.add_view(cls, attr='worksheet', route_name='{}.worksheet'.format(route_prefix), permission='{}.worksheet'.format(permission_prefix)) - config.add_route('{}.worksheet_update'.format(route_prefix), '{}/{{{}}}/worksheet/update'.format(url_prefix, model_key)) + config.add_route('{}.worksheet_update'.format(route_prefix), '{}/{{{}}}/worksheet/update'.format(url_prefix, model_key), + request_method='POST') config.add_view(cls, attr='worksheet_update', route_name='{}.worksheet_update'.format(route_prefix), renderer='json', permission='{}.worksheet'.format(permission_prefix)) + # worksheet file + if cls.has_worksheet_file: + + # download worksheet + config.add_route('{}.download_worksheet'.format(route_prefix), '{}/download-worksheet'.format(instance_url_prefix)) + config.add_view(cls, attr='download_worksheet', route_name='{}.download_worksheet'.format(route_prefix), + permission='{}.worksheet'.format(permission_prefix)) + + # upload worksheet + config.add_route('{}.upload_worksheet'.format(route_prefix), '{}/upload-worksheet'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='upload_worksheet', route_name='{}.upload_worksheet'.format(route_prefix), + permission='{}.worksheet'.format(permission_prefix)) + # refresh batch data - config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix)) - config.add_view(cls, attr='refresh', route_name='{}.refresh'.format(route_prefix), - permission='{}.refresh'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix), - "Refresh data for {}".format(model_title)) + if cls.refreshable: + config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix)) + config.add_view(cls, attr='refresh', route_name='{}.refresh'.format(route_prefix), + permission='{}.refresh'.format(permission_prefix)) + 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") - # mobile mark complete - config.add_route('mobile.{}.mark_complete'.format(route_prefix), '/mobile{}/{{{}}}/mark-complete'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_mark_complete', route_name='mobile.{}.mark_complete'.format(route_prefix), + # toggle complete + config.add_route('{}.toggle_complete'.format(route_prefix), '{}/{{{}}}/toggle-complete'.format(url_prefix, model_key)) + config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix), permission='{}.edit'.format(permission_prefix)) - # mobile mark pending - config.add_route('mobile.{}.mark_pending'.format(route_prefix), '/mobile{}/{{{}}}/mark-pending'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix), - permission='{}.edit'.format(permission_prefix)) + # refresh multiple batches (results) + if cls.results_refreshable: + config.add_route('{}.refresh_results'.format(route_prefix), '{}/refresh-results'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='refresh_results', route_name='{}.refresh_results'.format(route_prefix), + permission='{}.refresh'.format(permission_prefix)) # execute (multiple) batch results - config.add_route('{}.execute_results'.format(route_prefix), '{}/execute-results'.format(url_prefix)) - config.add_view(cls, attr='execute_results', route_name='{}.execute_results'.format(route_prefix), - permission='{}.execute_multiple'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.execute_multiple'.format(permission_prefix), - "Execute multiple {}".format(model_title_plural)) + if cls.results_executable: + config.add_route('{}.execute_results'.format(route_prefix), '{}/execute-results'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='execute_results', route_name='{}.execute_results'.format(route_prefix), + permission='{}.execute_multiple'.format(permission_prefix)) + config.add_tailbone_permission(permission_prefix, '{}.execute_multiple'.format(permission_prefix), + "Execute multiple {}".format(model_title_plural)) class FileBatchMasterView(BatchMasterView): """ Base class for all file-based "batch master" views. """ + downloadable = True @property def upload_dir(self): @@ -1008,125 +1564,27 @@ class FileBatchMasterView(BatchMasterView): os.makedirs(uploads) return uploads - def _preconfigure_fieldset(self, fs): - super(FileBatchMasterView, self)._preconfigure_fieldset(fs) - fs.filename.set(label="Data File", renderer=FileFieldRenderer.new(self)) - if self.editing: - fs.filename.set(readonly=True) + def configure_form(self, f): + super().configure_form(f) + batch = f.model_instance - def configure_fieldset(self, fs): - """ - Apply final configuration to the main batch fieldset. Custom batch - views are encouraged to override this method. - """ + # filename if self.creating: - fs.configure( - include=[ - fs.filename, - ]) - + # TODO: what's up with this re-insertion again..? + # if 'filename' not in f.fields: + # f.fields.insert(0, 'filename') + f.set_type('filename', 'file') else: - batch = fs.model - if batch.executed: - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - fs.filename, - fs.executed, - fs.executed_by, - ]) - else: - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - fs.filename, - ]) - - def download(self): - """ - View for downloading the data file associated with a batch. - """ - batch = self.get_instance() - if not batch: - raise httpexceptions.HTTPNotFound() - path = batch.filepath(self.rattail_config) - response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = str(os.path.getsize(path)) - filename = os.path.basename(batch.filename).encode('ascii', 'replace') - response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(filename) - return response - - @classmethod - def defaults(cls, config): - cls._filebatch_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) - - @classmethod - def _filebatch_defaults(cls, config): - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_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 title - config.add_tailbone_permission_group(permission_prefix, model_title_plural) - - # download batch data file - config.add_route('{}.download'.format(route_prefix), '{}/{{uuid}}/download'.format(url_prefix)) - config.add_view(cls, attr='download', route_name='{}.download'.format(route_prefix), - permission='{}.download'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.download'.format(permission_prefix), - "Download existing {} data file".format(model_title)) + f.set_readonly('filename') + f.set_renderer('filename', self.render_downloadable_file) -class StatusRenderer(forms.renderers.EnumFieldRenderer): - """ - Custom renderer for ``status_code`` fields. Adds ``status_text`` value as - title attribute if it exists. - """ +class UploadWorksheet(colander.Schema): - def render_readonly(self, **kwargs): - value = self.raw_value - if value is None: - return '' - status_code_text = self.enumeration.get(value, unicode(value)) - row = self.field.parent.model - if row.status_text: - return HTML.tag('span', title=row.status_text, c=status_code_text) - return status_code_text + # this node is actually "replaced" when form is configured + worksheet_file = colander.SchemaNode(colander.String()) -class MobileBatchStatusFilter(grids.filters.MobileFilter): +class ToggleComplete(colander.MappingSchema): - value_choices = ['pending', 'complete', 'executed', 'all'] - - def __init__(self, model_class, key, **kwargs): - self.model_class = model_class - super(MobileBatchStatusFilter, self).__init__(key, **kwargs) - - def filter_equal(self, query, value): - - if value == 'pending': - return query.filter(self.model_class.executed == None)\ - .filter(sa.or_( - self.model_class.complete == None, - self.model_class.complete == False)) - - if value == 'complete': - return query.filter(self.model_class.executed == None)\ - .filter(self.model_class.complete == True) - - if value == 'executed': - return query.filter(self.model_class.executed != None) - - return query - - def iter_choices(self): - for value in self.value_choices: - yield value, prettify(value) + complete = colander.SchemaNode(colander.Boolean()) diff --git a/tailbone/views/batch/core2.py b/tailbone/views/batch/core2.py deleted file mode 100644 index d2542803..00000000 --- a/tailbone/views/batch/core2.py +++ /dev/null @@ -1,124 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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 views for maintaining batches -""" - -from __future__ import unicode_literals, absolute_import - -import six - -from rattail.db import model - -from webhelpers2.html import HTML - -from tailbone import grids -from tailbone.views import MasterView2 -from tailbone.views.batch import BatchMasterView, FileBatchMasterView -from tailbone.views.batch.core import MobileBatchStatusFilter - - -class BatchMasterView2(MasterView2, BatchMasterView): - """ - Base class for all "batch master" views - """ - - grid_columns = [ - 'id', - 'created', - 'created_by', - 'rowcount', - 'status_code', - 'complete', - 'executed', - 'executed_by', - ] - - def configure_grid(self, g): - super(BatchMasterView2, self).configure_grid(g) - - g.joiners['created_by'] = lambda q: q.join(model.User, model.User.uuid == self.model_class.created_by_uuid) - g.joiners['executed_by'] = lambda q: q.outerjoin(model.User, model.User.uuid == self.model_class.executed_by_uuid) - - g.filters['executed'].default_active = True - g.filters['executed'].default_verb = 'is_null' - - # TODO: not sure this todo is still relevant? - # TODO: in some cases grid has no sorters yet..e.g. when building query for bulk-delete - # if hasattr(g, 'sorters'): - g.sorters['created_by'] = g.make_sorter(model.User.username) - g.sorters['executed_by'] = g.make_sorter(model.User.username) - - g.default_sortkey = 'id' - g.default_sortdir = 'desc' - - g.set_enum('status_code', self.model_class.STATUS) - - g.set_type('created', 'datetime') - g.set_type('executed', 'datetime') - - g.set_renderer('id', self.render_batch_id) - - g.set_link('id') - g.set_link('description') - g.set_link('created') - g.set_link('executed') - - g.set_label('id', "Batch ID") - g.set_label('created_by', "Created by") - g.set_label('rowcount', "Rows") - g.set_label('status_code', "Status") - g.set_label('executed_by', "Executed by") - - def render_batch_id(self, batch, column): - return batch.id_str - - def configure_row_grid(self, g): - super(BatchMasterView2, self).configure_row_grid(g) - - g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) - - g.default_sortkey = 'sequence' - - g.set_enum('status_code', self.model_row_class.STATUS) - - g.set_renderer('status_code', self.render_row_status) - - g.set_label('sequence', "Seq.") - g.set_label('status_code', "Status") - g.set_label('item_id', "Item ID") - - def render_row_status(self, row, column): - code = row.status_code - if code is None: - return "" - text = self.model_row_class.STATUS.get(code, six.text_type(code)) - if row.status_text: - return HTML.tag('span', title=row.status_text, c=text) - return text - - -class FileBatchMasterView2(BatchMasterView2, FileBatchMasterView): - """ - Base class for all file-based "batch master" views - """ diff --git a/tailbone/views/batch/delproduct.py b/tailbone/views/batch/delproduct.py new file mode 100644 index 00000000..60561e96 --- /dev/null +++ b/tailbone/views/batch/delproduct.py @@ -0,0 +1,128 @@ +# -*- 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/>. +# +################################################################################ +""" +Views for "delete product" batches +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model + +from tailbone.views.batch import BatchMasterView + + +class DeleteProductBatchView(BatchMasterView): + """ + Master view for delete product batches. + """ + model_class = model.DeleteProductBatch + model_row_class = model.DeleteProductBatchRow + default_handler_spec = 'rattail.batch.delproduct:DeleteProductBatchHandler' + route_prefix = 'batch.delproduct' + url_prefix = '/batches/delproduct' + template_prefix = '/batch/delproduct' + creatable = False + bulk_deletable = True + rows_bulk_deletable = True + + form_fields = [ + 'id', + 'description', + 'notes', + 'inactivity_months', + 'created', + 'created_by', + 'rowcount', + 'status_code', + 'executed', + 'executed_by', + ] + + row_grid_columns = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'pack_size', + 'department_name', + 'subdepartment_name', + 'present_in_scale', + 'date_created', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'product', + 'upc', + 'brand_name', + 'description', + 'size', + 'pack_size', + 'department_number', + 'department_name', + 'subdepartment_number', + 'subdepartment_name', + 'present_in_scale', + 'date_created', + 'status_code', + 'status_text', + ] + + def template_kwargs_view(self, **kwargs): + kwargs = super(DeleteProductBatchView, self).template_kwargs_view(**kwargs) + batch = kwargs['batch'] + + kwargs['rows_with_inventory'] = [row for row in batch.active_rows() + if row.status_code == row.STATUS_HAS_INVENTORY] + + return kwargs + + def row_grid_extra_class(self, row, i): + if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + return 'warning' + if row.status_code in (row.STATUS_DELETE_NOT_ALLOWED, + row.STATUS_HAS_INVENTORY, + row.STATUS_PENDING_CUSTOMER_ORDERS): + return 'notice' + + def configure_row_grid(self, g): + super(DeleteProductBatchView, self).configure_row_grid(g) + + # pack_size + g.set_type('pack_size', 'quantity') + + def configure_row_form(self, f): + super(DeleteProductBatchView, self).configure_row_form(f) + row = f.model_instance + + # upc + f.set_renderer('upc', self.render_upc) + + # pack_size + f.set_type('pack_size', 'quantity') + + +def includeme(config): + DeleteProductBatchView.defaults(config) 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 new file mode 100644 index 00000000..ea4e1c74 --- /dev/null +++ b/tailbone/views/batch/importer.py @@ -0,0 +1,307 @@ +# -*- 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 importer batches +""" + +import sqlalchemy as sa + +from rattail.db.model import ImporterBatch + +import colander + +from tailbone.views.batch import BatchMasterView + + +class ImporterBatchView(BatchMasterView): + """ + Master view for importer batches. + """ + model_class = ImporterBatch + default_handler_spec = 'rattail.batch.importer:ImporterBatchHandler' + route_prefix = 'batch.importer' + url_prefix = '/batches/importer' + template_prefix = '/batch/importer' + creatable = False + refreshable = False + bulk_deletable = True + rows_downloadable_csv = False + rows_bulk_deletable = True + + labels = { + 'host_title': "Source", + 'local_title': "Target", + 'importer_key': "Model", + } + + grid_columns = [ + 'id', + 'description', + 'host_title', + 'local_title', + 'importer_key', + 'created', + 'created_by', + 'rowcount', + 'executed', + 'executed_by', + ] + + form_fields = [ + 'id', + 'description', + 'import_handler_spec', + 'host_title', + 'local_title', + 'importer_key', + 'notes', + 'created', + 'created_by', + 'row_table', + 'rowcount', + 'executed', + 'executed_by', + ] + + row_grid_columns = [ + 'sequence', + 'object_key', + 'object_str', + 'status_code', + ] + + def configure_form(self, f): + super().configure_form(f) + + # readonly fields + f.set_readonly('import_handler_spec') + f.set_readonly('host_title') + f.set_readonly('local_title') + f.set_readonly('importer_key') + f.set_readonly('row_table') + + def make_status_breakdown(self, batch, **kwargs): + """ + Returns a simple list of 2-tuples, each of which has the status display + title as first member, and number of rows with that status as second + member. + """ + if kwargs.get('rows') is None: + 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().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().delete_instance(batch) + + def make_row_table(self, name): + if not hasattr(self, 'current_row_table'): + metadata = sa.MetaData(schema='batch') + try: + self.current_row_table = sa.Table(name, metadata, + autoload_with=self.Session.bind) + except sa.exc.NoSuchTableError: + self.current_row_table = None + + def get_row_data(self, batch): + self.make_row_table(batch.row_table) + return self.Session.query(self.current_row_table) + + def get_row_status_enum(self): + return self.enum.IMPORTER_BATCH_ROW_STATUS + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + def make_filter(field, **kwargs): + column = getattr(self.current_row_table.c, field) + g.set_filter(field, column, **kwargs) + + make_filter('object_key') + make_filter('object_str') + + 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) + g.sorters[field] = lambda q, d: q.order_by(getattr(column, d)()) + + make_sorter('sequence') + make_sorter('object_key') + make_sorter('object_str') + make_sorter('status_code') + + g.set_sort_defaults('sequence') + + g.set_label('object_str', "Object Description") + + g.set_link('sequence') + g.set_link('object_key') + g.set_link('object_str') + + def row_grid_extra_class(self, row, i): + if row.status_code == self.enum.IMPORTER_BATCH_ROW_STATUS_DELETE: + return 'warning' + if row.status_code in (self.enum.IMPORTER_BATCH_ROW_STATUS_CREATE, + self.enum.IMPORTER_BATCH_ROW_STATUS_UPDATE): + return 'notice' + + def get_row_action_route_kwargs(self, row): + return { + 'uuid': self.current_row_table.name, + 'row_uuid': row.uuid, + } + + def get_row_instance(self): + batch_uuid = self.request.matchdict['uuid'] + row_uuid = self.request.matchdict['row_uuid'] + self.make_row_table(batch_uuid) + return self.Session.query(self.current_row_table)\ + .filter(self.current_row_table.c.uuid == row_uuid)\ + .one() + + def get_parent(self, row): + uuid = self.current_row_table.name + return self.Session.get(ImporterBatch, uuid) + + def get_row_instance_title(self, row): + if row.object_str: + return row.object_str + if row.object_key: + return row.object_key + return "Row {}".format(row.sequence) + + def template_kwargs_view_row(self, **kwargs): + batch = kwargs['parent_instance'] + row = kwargs['instance'] + kwargs['batch'] = batch + kwargs['instance_title'] = batch.id_str + + fields = set() + old_values = {} + new_values = {} + for col in self.current_row_table.c: + if col.name.startswith('key_'): + field = col.name[4:] + fields.add(field) + old_values[field] = new_values[field] = getattr(row, col.name) + elif col.name.startswith('pre_'): + field = col.name[4:] + fields.add(field) + old_values[field] = getattr(row, col.name) + elif col.name.startswith('post_'): + field = col.name[5:] + fields.add(field) + new_values[field] = getattr(row, col.name) + + kwargs['diff_fields'] = sorted(fields) + kwargs['diff_old_values'] = old_values + kwargs['diff_new_values'] = new_values + return kwargs + + def make_row_form(self, instance=None, **kwargs): + fields = ['sequence', 'object_key', 'object_str', 'status_code'] + for col in self.current_row_table.c: + if col.name.startswith('key_'): + fields.append(col.name) + + if kwargs.get('fields') is None: + kwargs['fields'] = fields + + row = dict([(field, getattr(instance, field)) + for field in fields]) + row['status_text'] = instance.status_text + + kwargs.setdefault('schema', colander.Schema()) + kwargs.setdefault('cancel_url', None) + return super().make_row_form(instance=row, **kwargs) + + def configure_row_form(self, f): + """ + Configure the row form. + """ + # object_str + f.set_label('object_str', "Object Description") + + # status_code + f.set_renderer('status_code', self.render_row_status_code) + f.set_label('status_code', "Status") + + def render_row_status_code(self, row, field): + status = self.enum.IMPORTER_BATCH_ROW_STATUS[row['status_code']] + if row['status_code'] == self.enum.IMPORTER_BATCH_ROW_STATUS_UPDATE and row['status_text']: + return "{} ({})".format(status, row['status_text']) + return status + + def delete_row(self): + row = self.get_row_instance() + if not row: + raise self.notfound() + + batch = self.get_parent(row) + query = self.current_row_table.delete().where(self.current_row_table.c.uuid == row.uuid) + query.execute() + batch.rowcount -= 1 + return self.redirect(self.get_action_url('view', batch)) + + def bulk_delete_rows(self): + batch = self.get_instance() + 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])) + 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): + defaults(config) diff --git a/tailbone/views/batch/inventory.py b/tailbone/views/batch/inventory.py new file mode 100644 index 00000000..e9f72ceb --- /dev/null +++ b/tailbone/views/batch/inventory.py @@ -0,0 +1,555 @@ +# -*- 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 inventory batches +""" + +import re +import decimal +import logging +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 + +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML, tags + +from tailbone import forms, grids +from tailbone.views import MasterView +from tailbone.views.batch import BatchMasterView + + +log = logging.getLogger(__name__) + + +class InventoryBatchView(BatchMasterView): + """ + Master view for inventory batches. + """ + model_class = model.InventoryBatch + model_title_plural = "Inventory Batches" + default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' + route_prefix = 'batch.inventory' + url_prefix = '/batch/inventory' + index_title = "Inventory" + rows_creatable = True + bulk_deletable = True + + # set to True for the UI to "prefer" case amounts, as opposed to unit + prefer_cases = False + + labels = { + 'mode': "Count Mode", + } + + grid_columns = [ + 'id', + 'created', + 'created_by', + 'description', + 'mode', + 'rowcount', + 'total_cost', + 'executed', + 'executed_by', + ] + + form_fields = [ + 'id', + 'description', + 'notes', + 'created', + 'created_by', + 'handheld_batches', + 'mode', + 'reason_code', + 'total_cost', + 'rowcount', + 'complete', + 'executed', + 'executed_by', + ] + + model_row_class = model.InventoryBatchRow + rows_editable = True + + row_labels = { + 'upc': "UPC", + 'previous_units_on_hand': "Prev. On Hand", + } + + row_grid_columns = [ + 'sequence', + 'upc', + 'item_id', + 'brand_name', + 'description', + 'size', + 'previous_units_on_hand', + 'cases', + 'units', + 'unit_cost', + 'total_cost', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'status_code', + 'previous_units_on_hand', + 'case_quantity', + 'cases', + 'units', + 'unit_cost', + 'total_cost', + 'variance', + ] + + def configure_grid(self, g): + super(InventoryBatchView, self).configure_grid(g) + + # mode + g.set_enum('mode', self.enum.INVENTORY_MODE) + g.filters['mode'].set_value_renderer( + grids.filters.EnumValueRenderer(self.enum.INVENTORY_MODE)) + + # total_cost + g.set_type('total_cost', 'currency') + + def mutable_batch(self, batch): + return not batch.executed and not batch.complete and batch.mode != self.enum.INVENTORY_MODE_ZERO_ALL + + def allow_worksheet(self, batch): + return self.mutable_batch(batch) + + def get_available_modes(self): + permission_prefix = self.get_permission_prefix() + if self.request.is_root: + modes = self.handler.get_count_modes() + else: + modes = self.handler.get_allowed_count_modes( + self.Session(), self.request.user, + permission_prefix=permission_prefix) + + modes = OrderedDict([(mode['code'], mode['label']) + for mode in modes]) + return modes + + def configure_form(self, f): + super(InventoryBatchView, self).configure_form(f) + + # mode + modes = self.get_available_modes() + f.set_enum('mode', modes) + f.set_label('mode', "Count Mode") + if len(modes) == 1: + f.set_widget('mode', forms.widgets.ReadonlyWidget()) + f.set_default('mode', list(modes)[0]) + + # total_cost + if self.creating: + f.remove_field('total_cost') + else: + f.set_readonly('total_cost') + f.set_type('total_cost', 'currency') + + # handheld_batches + if self.creating: + f.remove_field('handheld_batches') + else: + f.set_readonly('handheld_batches') + f.set_renderer('handheld_batches', self.render_handheld_batches) + + # complete + if self.creating: + f.remove_field('complete') + + def render_handheld_batches(self, inventory_batch, field): + items = [] + for handheld in inventory_batch._handhelds: + text = handheld.handheld.id_str + url = self.request.route_url('batch.handheld.view', uuid=handheld.handheld_uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) + + def row_editable(self, row): + return self.mutable_batch(row.batch) + + def row_deletable(self, row): + return self.mutable_batch(row.batch) + + def save_edit_row_form(self, form): + row = form.model_instance + batch = row.batch + if batch.total_cost is not None and row.total_cost is not None: + batch.total_cost -= row.total_cost + return super(InventoryBatchView, self).save_edit_row_form(form) + + def delete_row(self): + row = self.Session.get(model.InventoryBatchRow, self.request.matchdict['row_uuid']) + if not row: + raise self.notfound() + batch = row.batch + if batch.total_cost is not None and row.total_cost is not None: + batch.total_cost -= row.total_cost + return super(InventoryBatchView, self).delete_row() + + def create_row(self): + """ + Desktop workflow view for adding items to inventory batch. + """ + batch = self.get_instance() + 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 self.request.method == 'POST': + if form.validate(): + + 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.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.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()) + + 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', { + 'batch': batch, + 'instance': batch, + 'instance_title': title, + 'index_title': "{}: {}".format(self.get_model_title(), title), + 'index_url': self.get_action_url('view', batch), + 'form': form, + 'dform': form.make_deform_form(), + 'allow_cases': self.allow_cases(batch), + 'prefer_cases': self.prefer_cases, + }) + + # TODO: deprecate / remove this + def allow_cases(self, batch): + return self.handler.allow_cases(batch) + + # TODO: deprecate / remove this + def should_aggregate_products(self, batch): + """ + Must return a boolean indicating whether rows should be aggregated by + product for the given batch. + """ + return self.handler.should_aggregate_products(batch) + + # TODO: deprecate / remove + def find_type2_product(self, entry): + return self.handler.get_type2_product_info(self.Session(), entry) + + def desktop_lookup(self): + """ + Try to locate a product by UPC, and validate it in the context of + current batch, returning some data for client JS. + """ + batch = self.get_instance() + if batch.executed: + return { + 'error': "Current batch has already been executed", + 'redirect': self.get_action_url('view', batch), + } + entry = self.request.GET.get('upc', '') + aggregate = self.should_aggregate_products(batch) + + type2 = self.find_type2_product(entry) + if type2: + product, price = type2 + else: + product = self.find_product(entry) + + force_unit_item = True # TODO: make configurable? + unit_forced = False + if force_unit_item and product and product.is_pack_item(): + product = product.unit + unit_forced = True + + data = self.product_info(product) + if type2: + data['type2'] = True + if not aggregate: + if price is None: + data['units'] = 1 + else: + data['units'] = float((price / product.regular_price.price).quantize(decimal.Decimal('0.01'))) + + result = {'product': data, 'upc_raw': entry, 'upc': None, 'force_unit_item': unit_forced} + if not data: + upc = re.sub(r'\D', '', entry.strip()) + if upc: + upc = GPC(upc) + result['upc'] = str(upc) + result['upc_pretty'] = upc.pretty() + result['image_url'] = pod.get_image_url(self.rattail_config, upc) + + if product and aggregate: + row = self.find_row_for_product(batch, product) + if row: + result['already_present_in_batch'] = True + result['cases'] = float(row.cases) if row.cases is not None else None + result['units'] = float(row.units) if row.units is not None else None + + return result + + # TODO: deprecate / remove + def find_row_for_product(self, batch, product): + return self.handler.find_row_for_product(self.Session(), batch, product) + + # TODO: deprecate / remove (?) + def find_product(self, entry): + lookup_fields = [ + 'uuid', + '_product_key_', + ] + + 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'] = str(product.upc) + data['upc_pretty'] = product.upc.pretty() + data['full_description'] = product.full_description + data['brand_name'] = str(product.brand or '') + data['description'] = product.description + data['size'] = product.size + data['case_quantity'] = 1 # default + data['cost_found'] = False + data['image_url'] = pod.get_image_url(self.rattail_config, product.upc) + return data + + def add_row_for_upc(self, batch, entry, warn_if_present=False): + """ + Add a row to the batch for the given UPC, if applicable. + """ + row = self.handler.quick_entry(self.Session(), batch, entry) + if row: + + if row.product and getattr(row.product, '__forced_unit_item__', False): + self.request.session.flash("You scanned a pack item, but must count the units instead.", 'error') + + if warn_if_present and getattr(row, '__existing_reused__', False): + self.request.session.flash("Product already exists in batch; please confirm counts", 'error') + + return row + + def template_kwargs_view_row(self, **kwargs): + row = kwargs['instance'] + kwargs['product_image_url'] = pod.get_image_url(self.rattail_config, row.upc) + return kwargs + + def get_batch_kwargs(self, batch, **kwargs): + kwargs = super(InventoryBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs['mode'] = batch.mode + kwargs['complete'] = False + kwargs['reason_code'] = batch.reason_code + return kwargs + + def get_row_instance_title(self, row): + if row.upc: + return row.upc.pretty() + if row.item_id: + return row.item_id + return "row {}".format(row.sequence) + + def configure_row_grid(self, g): + super(InventoryBatchView, self).configure_row_grid(g) + + # quantity fields + g.set_type('previous_units_on_hand', 'quantity') + g.set_type('cases', 'quantity') + g.set_type('units', 'quantity') + + # currency fields + g.set_type('unit_cost', 'currency') + g.set_type('total_cost', 'currency') + + # short labels + g.set_label('brand_name', "Brand") + g.set_label('status_code', "Status") + + # links + g.set_link('upc') + g.set_link('item_id') + g.set_link('description') + + 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(InventoryBatchView, self).configure_row_form(f) + row = f.model_instance + + # readonly fields + f.set_readonly('upc') + f.set_readonly('item_id') + f.set_readonly('brand_name') + f.set_readonly('description') + f.set_readonly('size') + f.set_readonly('previous_units_on_hand') + f.set_readonly('case_quantity') + f.set_readonly('variance') + f.set_readonly('total_cost') + + # quantity fields + f.set_type('case_quantity', 'quantity') + f.set_type('previous_units_on_hand', 'quantity') + f.set_type('cases', 'quantity') + f.set_type('units', 'quantity') + f.set_type('variance', 'quantity') + + # currency fields + f.set_type('unit_cost', 'currency') + f.set_type('total_cost', 'currency') + + # upc + f.set_renderer('upc', self.render_upc) + + # cases + if self.editing: + if not self.allow_cases(row.batch): + f.set_readonly('cases') + + @classmethod + def defaults(cls, config): + cls._batch_defaults(config) + cls._defaults(config) + cls._inventory_defaults(config) + + @classmethod + def _inventory_defaults(cls, config): + rattail_config = config.registry.settings['rattail_config'] + model_key = cls.get_model_key() + model_title = cls.get_model_title() + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # we need batch handler to determine available permissions + factory = cls.get_handler_factory(rattail_config) + handler = factory(rattail_config) + + # extra perms for creating batches per "mode" + config.add_tailbone_permission(permission_prefix, '{}.create.replace'.format(permission_prefix), + "Create new {} with 'replace' mode".format(model_title)) + if handler.allow_zero_all: + config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix), + "Create new {} with 'zero' mode".format(model_title)) + if handler.allow_variance: + config.add_tailbone_permission(permission_prefix, '{}.create.variance'.format(permission_prefix), + "Create new {} with 'variance' mode".format(model_title)) + + # row UPC lookup, for desktop + config.add_route('{}.desktop_lookup'.format(route_prefix), '{}/{{{}}}/desktop-form/lookup'.format(url_prefix, model_key)) + config.add_view(cls, attr='desktop_lookup', route_name='{}.desktop_lookup'.format(route_prefix), + renderer='json', permission='{}.create_row'.format(permission_prefix)) + + +# TODO: this is a stopgap measure to fix an obvious bug, which exists when the +# session is not provided by the view at runtime (i.e. when it was instead +# being provided by the type instance, which was created upon app startup). +@colander.deferred +def valid_product(node, kw): + session = kw['session'] + def validate(node, value): + product = session.get(model.Product, value) + if not product: + raise colander.Invalid(node, "Product not found") + return product.uuid + return validate + + +class DesktopForm(colander.Schema): + + product = colander.SchemaNode(colander.String(), + validator=valid_product) + + upc = colander.SchemaNode(forms.types.GPCType()) + + brand_name = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + size = colander.SchemaNode(colander.String(), missing=colander.null) + + case_quantity = colander.SchemaNode(colander.Decimal()) + + cases = colander.SchemaNode(colander.Decimal(), + missing=None) + + units = colander.SchemaNode(colander.Decimal(), + missing=None) + + +def includeme(config): + InventoryBatchView.defaults(config) diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py new file mode 100644 index 00000000..7291b05e --- /dev/null +++ b/tailbone/views/batch/labels.py @@ -0,0 +1,224 @@ +# -*- 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 label batches +""" + +from rattail.db import model + +from deform import widget as dfwidget +from webhelpers2.html import HTML, tags + +from tailbone import forms +from tailbone.views.batch import BatchMasterView + + +class LabelBatchView(BatchMasterView): + """ + Master view for label batches. + """ + model_class = model.LabelBatch + model_row_class = model.LabelBatchRow + default_handler_spec = 'rattail.batch.labels:LabelBatchHandler' + model_title_plural = "Label Batches" + route_prefix = 'labels.batch' + url_prefix = '/labels/batches' + template_prefix = '/batch/labels' + bulk_deletable = True + rows_editable = True + rows_bulk_deletable = True + cloneable = True + + row_grid_columns = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'regular_price', + 'sale_price', + 'label_profile', + 'label_quantity', + 'status_code', + ] + + form_fields = [ + 'id', + 'description', + 'static_prices', + 'label_profile', + 'notes', + 'created', + 'created_by', + 'handheld_batches', + 'rowcount', + 'executed', + 'executed_by', + ] + + row_labels = { + '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 = [ + 'sequence', + 'upc', + 'product', + 'brand_name', + 'description', + 'size', + 'department_number', + 'department_name', + 'regular_price', + 'pack_quantity', + 'pack_price', + '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', + 'case_quantity', + 'label_profile', + 'label_quantity', + 'status_code', + 'status_text', + ] + + def configure_form(self, f): + super().configure_form(f) + + # handheld_batches + if self.creating: + f.remove('handheld_batches') + else: + batch = self.get_instance() + if not batch._handhelds: + f.remove_field('handheld_batches') + else: + f.set_readonly('handheld_batches') + f.set_renderer('handheld_batches', self.render_handheld_batches) + + # label profile + if self.creating or self.editing: + if 'label_profile' in f.fields: + f.replace('label_profile', 'label_profile_uuid') + # TODO: should restrict somehow? just allow override? + profiles = self.Session.query(model.LabelProfile) + values = [(p.uuid, str(p)) + for p in profiles] + require_profile = False + if not require_profile: + values.insert(0, ('', "(none)")) + f.set_widget('label_profile_uuid', dfwidget.SelectWidget(values=values)) + f.set_label('label_profile_uuid', "Label Profile") + + def render_handheld_batches(self, label_batch, field): + items = [] + for handheld in label_batch._handhelds: + text = handheld.handheld.id_str + url = self.request.route_url('batch.handheld.view', uuid=handheld.handheld_uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + # short labels + g.set_label('brand_name', "Brand") + g.set_label('regular_price', "Reg Price") + g.set_label('label_quantity', "Qty") + + def row_grid_extra_class(self, row, i): + if row.status_code != row.STATUS_OK: + return 'warning' + + def configure_row_form(self, f): + super().configure_row_form(f) + + # readonly fields + f.set_readonly('sequence') + f.set_readonly('product') + f.set_readonly('upc') + f.set_readonly('brand_name') + f.set_readonly('description') + f.set_readonly('size') + f.set_readonly('department_number') + f.set_readonly('department_name') + f.set_readonly('regular_price') + f.set_readonly('pack_quantity') + f.set_readonly('pack_price') + f.set_readonly('sale_price') + f.set_readonly('sale_start') + f.set_readonly('sale_stop') + f.set_readonly('vendor_id') + f.set_readonly('vendor_name') + f.set_readonly('vendor_item_code') + f.set_readonly('case_quantity') + f.set_readonly('status_code') + f.set_readonly('status_text') + + if self.editing: + f.remove_fields( + 'brand_name', + 'description', + 'size', + 'pack_quantity', + 'pack_price', + 'sale_start', + 'sale_stop', + 'vendor_id', + 'vendor_name', + 'vendor_item_code', + 'case_quantity', + ) + else: + f.remove_field('product') + + # label_profile + if self.editing: + f.replace('label_profile', 'label_profile_uuid') + f.set_label('label_profile_uuid', "Label Type") + profiles = self.Session.query(model.LabelProfile)\ + .filter(model.LabelProfile.visible == True)\ + .order_by(model.LabelProfile.ordinal) + profile_values = [(p.uuid, str(p)) + for p in profiles] + f.set_widget('label_profile_uuid', forms.widgets.JQuerySelectWidget(values=profile_values)) + + +def includeme(config): + LabelBatchView.defaults(config) diff --git a/tailbone/views/batch/newproduct.py b/tailbone/views/batch/newproduct.py new file mode 100644 index 00000000..bd46ad52 --- /dev/null +++ b/tailbone/views/batch/newproduct.py @@ -0,0 +1,229 @@ +# -*- 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 new product batches +""" + +from rattail.db import model + +from deform import widget as dfwidget + +from tailbone.views.batch import BatchMasterView + + +class NewProductBatchView(BatchMasterView): + """ + Master view for new product batches. + """ + model_class = model.NewProductBatch + model_row_class = model.NewProductBatchRow + default_handler_spec = 'rattail.batch.newproduct:NewProductBatchHandler' + route_prefix = 'batch.newproduct' + url_prefix = '/batches/newproduct' + template_prefix = '/batch/newproduct' + downloadable = True + bulk_deletable = True + 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', + 'executed', + 'executed_by', + ] + + row_labels = { + 'vendor_id': "Vendor ID", + } + + row_grid_columns = [ + 'sequence', + '_product_key_', + 'brand_name', + 'description', + 'size', + 'vendor', + 'vendor_item_code', + 'department_name', + 'subdepartment_name', + 'regular_price', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'product', + '_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', + 'regular_price', + 'regular_price_multiple', + 'pack_price', + 'pack_price_multiple', + 'suggested_price', + 'category_code', + 'category', + 'family_code', + '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().configure_form(f) + + # input_filename + if self.creating: + f.set_type('input_filename', 'file') + else: + 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) + + g.set_type('case_cost', 'currency') + g.set_type('unit_cost', 'currency') + g.set_type('regular_price', 'currency') + 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, + row.STATUS_VENDOR_NOT_FOUND, + row.STATUS_DEPT_NOT_FOUND, + row.STATUS_SUBDEPT_NOT_FOUND): + return 'warning' + if row.status_code in (row.STATUS_CATEGORY_NOT_FOUND, + row.STATUS_FAMILY_NOT_FOUND, + row.STATUS_REPORTCODE_NOT_FOUND, + row.STATUS_CANNOT_CALCULATE_PRICE): + return 'notice' + + def configure_row_form(self, f): + super().configure_row_form(f) + + f.set_readonly('product') + f.set_readonly('vendor') + f.set_readonly('department') + f.set_readonly('subdepartment') + f.set_readonly('category') + f.set_readonly('family') + f.set_readonly('report') + + 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 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 273430b1..5b5d013b 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.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,12 +24,12 @@ Views for pricing batches """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model +from rattail.time import localtime -from tailbone import forms -from tailbone.views.batch import BatchMasterView2 as BatchMasterView +from webhelpers2.html import tags, HTML + +from tailbone.views.batch import BatchMasterView class PricingBatchView(BatchMasterView): @@ -42,101 +42,346 @@ class PricingBatchView(BatchMasterView): model_title_plural = "Pricing Batches" route_prefix = 'batch.pricing' url_prefix = '/batches/pricing' - creatable = False + template_prefix = '/batch/pricing' + creatable = True + downloadable = True bulk_deletable = True rows_editable = True rows_bulk_deletable = True + configurable = True + + labels = { + 'min_diff_threshold': "Min $ Diff", + 'min_diff_percent': "Min % Diff", + 'auto_generate_from_srp_breach': "Automatic (from SRP Breach)", + } grid_columns = [ 'id', 'description', + 'start_date', 'created', 'created_by', 'rowcount', # 'status_code', - # 'complete', + 'complete', 'executed', 'executed_by', ] + form_fields = [ + 'id', + 'input_filename', + 'description', + 'start_date', + 'min_diff_threshold', + 'min_diff_percent', + 'calculate_for_manual', + 'auto_generate_from_srp_breach', + 'notes', + 'created', + 'created_by', + 'rowcount', + 'shelved', + 'complete', + 'executed', + 'executed_by', + ] + + row_labels = { + 'upc': "UPC", + 'vendor_id': "Vendor ID", + 'regular_unit_cost': "Reg. Cost", + 'price_diff': "$ Diff", + 'price_diff_percent': "% Diff", + 'brand_name': "Brand", + 'price_markup': "Markup", + 'manually_priced': "Manual", + } + row_grid_columns = [ 'sequence', 'upc', 'brand_name', 'description', 'size', + 'vendor_id', 'discounted_unit_cost', 'old_price', 'new_price', 'price_margin', 'price_diff', + 'price_diff_percent', + 'manually_priced', 'status_code', ] - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id, - fs.description, - fs.min_diff_threshold, - fs.notes, - fs.created, - fs.created_by, - fs.executed, - fs.executed_by, - ]) + row_form_fields = [ + 'sequence', + 'product', + 'upc', + 'brand_name', + 'description', + 'size', + 'department_number', + 'department_name', + 'subdepartment_number', + 'subdepartment_name', + 'family_code', + 'report_code', + 'alternate_code', + 'vendor', + 'vendor_item_code', + 'regular_unit_cost', + 'discounted_unit_cost', + 'suggested_price', + 'old_price', + 'new_price', + 'price_diff', + 'price_diff_percent', + 'price_markup', + 'price_margin', + 'old_price_margin', + 'margin_diff', + 'status_code', + 'status_text', + ] + + def allow_future_pricing(self): + return self.batch_handler.allow_future() + + def configure_form(self, 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') + + # input_filename + if self.creating: + f.set_type('input_filename', 'file') + else: + f.set_readonly('input_filename') + f.set_renderer('input_filename', self.render_downloadable_file) + + # auto_generate_from_srp_breach + if self.creating: + f.set_type('auto_generate_from_srp_breach', 'boolean') + else: + f.remove_field('auto_generate_from_srp_breach') + + # note, the input file is normally required, but should *not* be if the + # user wants to auto-generate the new batch + if self.request.method == 'POST': + if self.request.POST.get('auto_generate_from_srp_breach') == 'true': + f.set_required('input_filename', False) + + def get_batch_kwargs(self, 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 + + # are we auto-generating from SRP breach? + if self.request.POST.get('auto_generate_from_srp_breach') == 'true': + + # assign batch param + params = kwargs.get('params', {}) + params['auto_generate_from_srp_breach'] = True + kwargs['params'] = params + + # provide default description + if not kwargs.get('description'): + kwargs['description'] = "auto-generated from SRP breach" + + 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) + g.set_filter('vendor_id', model.Vendor.id) + g.set_renderer('vendor_id', self.render_vendor_id) + + g.set_renderer('subdepartment_number', self.render_subdepartment_number) g.set_type('old_price', 'currency') g.set_type('new_price', 'currency') g.set_type('price_diff', 'currency') - g.set_label('upc', "UPC") - g.set_label('brand_name', "Brand") - g.set_label('regular_unit_cost', "Reg. Cost") - g.set_label('price_margin', "Margin") - g.set_label('price_markup', "Markup") - g.set_label('price_diff', "Diff") + g.set_renderer('current_price', self.render_current_price) + + g.set_renderer('true_margin', self.render_true_margin) + + def render_vendor_id(self, row, field): + vendor = row.vendor + if not vendor: + return + text = vendor.id or "(no id)" + return HTML.tag('span', c=text, title=vendor.name) + + def render_subdepartment_number(self, row, field): + if row.subdepartment_number: + if row.subdepartment_name: + return HTML.tag('span', title=row.subdepartment_name, + c=str(row.subdepartment_number)) + return row.subdepartment_number + + def render_true_margin(self, row, field): + margin = row.true_margin + if margin: + margin = str(margin) + else: + margin = HTML.literal(' ') + if row.old_true_margin is not None: + title = "WAS: {}".format(row.old_true_margin) + else: + title = "WAS: NULL" + return HTML.tag('span', title=title, c=[margin]) def row_grid_extra_class(self, row, i): - if row.status_code == row.STATUS_CANNOT_CALCULATE_PRICE: - return 'warning' - if row.status_code in (row.STATUS_PRICE_INCREASE, row.STATUS_PRICE_DECREASE): - return 'notice' + extra_class = None - def _preconfigure_row_fieldset(self, fs): - super(PricingBatchView, self)._preconfigure_row_fieldset(fs) - fs.upc.set(label="UPC") - fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer) - fs.old_price.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.new_price.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.price_diff.set(renderer=forms.renderers.CurrencyFieldRenderer) + # primary class comes from row status + if row.status_code in (row.STATUS_PRODUCT_NOT_FOUND, + row.STATUS_CANNOT_CALCULATE_PRICE, + row.STATUS_PRICE_BREACHES_SRP): + extra_class = 'warning' + elif row.status_code in (row.STATUS_PRICE_INCREASE, + row.STATUS_PRICE_DECREASE): + extra_class = 'notice' - def configure_row_fieldset(self, fs): - fs.configure( - include=[ - fs.sequence, - fs.product, - fs.upc, - fs.brand_name, - fs.description, - fs.size, - fs.department_number, - fs.department_name, - fs.vendor, - fs.regular_unit_cost, - fs.discounted_unit_cost, - fs.old_price, - fs.new_price, - fs.price_diff, - fs.price_margin, - fs.price_markup, - fs.status_code, - fs.status_text, - ]) + # but we want to indicate presence of current price also + if row.current_price: + extra_class = "{} has-current-price".format(extra_class or '') + + return extra_class + + def render_current_price(self, row, field): + value = row.current_price + if value is None: + return "" + + if value < 0: + text = "(${:0,.2f})".format(0 - value) + else: + text = "${:0,.2f}".format(value) + + if row.current_price_ends: + ends = localtime(self.rattail_config, row.current_price_ends, from_utc=True) + ends = "ends on {}".format(ends.date()) + else: + ends = "never ends" + title = "{}, {}".format( + self.enum.PRICE_TYPE.get(row.current_price_type, "unknown type"), + ends) + return HTML.tag('span', title=title, c=text) + + def configure_row_form(self, f): + super().configure_row_form(f) + + # readonly fields + f.set_readonly('product') + f.set_readonly('upc') + f.set_readonly('brand_name') + f.set_readonly('description') + f.set_readonly('size') + f.set_readonly('department_number') + f.set_readonly('department_name') + f.set_readonly('vendor') + + # product + f.set_renderer('product', self.render_product) + + # currency fields + f.set_type('suggested_price', 'currency') + f.set_type('old_price', 'currency') + f.set_type('new_price', 'currency') + f.set_type('price_diff', 'currency') + + # vendor + f.set_renderer('vendor', self.render_vendor) + + def render_vendor(self, row, field): + vendor = row.vendor + if not vendor: + return "" + text = "({}) {}".format(vendor.id, vendor.name) + url = self.request.route_url('vendors.view', uuid=vendor.uuid) + return tags.link_to(text, url) + + def get_row_csv_fields(self): + fields = super().get_row_csv_fields() + + if 'vendor_uuid' in fields: + i = fields.index('vendor_uuid') + fields.insert(i + 1, 'vendor_id') + fields.insert(i + 2, 'vendor_abbreviation') + fields.insert(i + 3, 'vendor_name') + else: + fields.append('vendor_id') + fields.append('vendor_abbreviation') + fields.append('vendor_name') + + return fields + + # TODO: this is the same as xlsx row! should merge/share somehow? + def get_row_csv_row(self, row, fields): + csvrow = super().get_row_csv_row(row, fields) + + vendor = row.vendor + if 'vendor_id' in fields: + csvrow['vendor_id'] = (vendor.id or '') if vendor else '' + if 'vendor_abbreviation' in fields: + csvrow['vendor_abbreviation'] = (vendor.abbreviation or '') if vendor else '' + if 'vendor_name' in fields: + csvrow['vendor_name'] = (vendor.name or '') if vendor else '' + + return csvrow + + # TODO: this is the same as csv row! should merge/share somehow? + def get_row_xlsx_row(self, row, fields): + xlrow = super().get_row_xlsx_row(row, fields) + + vendor = row.vendor + if 'vendor_id' in fields: + xlrow['vendor_id'] = (vendor.id or '') if vendor else '' + if 'vendor_abbreviation' in fields: + xlrow['vendor_abbreviation'] = (vendor.abbreviation or '') if vendor else '' + if 'vendor_name' in fields: + xlrow['vendor_name'] = (vendor.name or '') if vendor else '' + + 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 new file mode 100644 index 00000000..590c3ff0 --- /dev/null +++ b/tailbone/views/batch/product.py @@ -0,0 +1,288 @@ +# -*- 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 generic product batches +""" + +from collections import OrderedDict + +from rattail.db.model import ProductBatch, ProductBatchRow + +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML + +from tailbone.views.batch import BatchMasterView + + +ACTION_OPTIONS = OrderedDict([ + ('make_label_batch', "Make a new Label Batch"), + ('make_pricing_batch', "Make a new Pricing Batch"), +]) + + +class ExecutionOptions(colander.Schema): + + action = colander.SchemaNode( + colander.String(), + validator=colander.OneOf(ACTION_OPTIONS), + widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items()))) + + +class ProductBatchView(BatchMasterView): + """ + Master view for product batches. + """ + model_class = ProductBatch + model_row_class = ProductBatchRow + default_handler_spec = 'rattail.batch.product:ProductBatchHandler' + route_prefix = 'batch.product' + url_prefix = '/batches/product' + template_prefix = '/batch/product' + downloadable = True + cloneable = True + bulk_deletable = True + execution_options_schema = ExecutionOptions + rows_bulk_deletable = True + + form_fields = [ + 'id', + 'input_filename', + 'description', + 'notes', + 'created', + 'created_by', + 'rowcount', + 'executed', + 'executed_by', + ] + + row_labels = { + 'reportcode': "Report Code", + } + + row_grid_columns = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'department', + 'subdepartment', + 'vendor', + 'regular_cost', + 'current_cost', + 'regular_price', + 'current_price', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'item_entry', + 'product', + 'item_id', + 'upc', + 'brand_name', + 'description', + 'size', + 'department_number', + 'department', + 'subdepartment_number', + 'subdepartment', + 'category', + 'family', + 'reportcode', + 'vendor', + 'vendor_item_code', + 'regular_cost', + 'current_cost', + 'current_cost_starts', + 'current_cost_ends', + 'regular_price', + 'current_price', + 'current_price_starts', + 'current_price_ends', + 'suggested_price', + 'status_code', + 'status_text', + ] + + def configure_form(self, f): + super().configure_form(f) + + # input_filename + if self.creating: + f.set_type('input_filename', 'file') + else: + f.set_readonly('input_filename') + f.set_renderer('input_filename', self.render_downloadable_file) + + def configure_row_grid(self, 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) + + g.set_joiner('department', lambda q: q.outerjoin(model.Department)) + g.set_sorter('department', model.Department.name) + + g.set_joiner('subdepartment', lambda q: q.outerjoin(model.Subdepartment)) + g.set_sorter('subdepartment', model.Subdepartment.name) + + g.set_type('regular_cost', 'currency') + g.set_type('current_cost', 'currency') + g.set_type('regular_price', 'currency') + g.set_type('current_price', 'currency') + g.set_type('suggested_price', 'currency') + + # highlight red for SRP breaches + g.set_renderer('suggested_price', self.render_suggested_price) + + def row_grid_extra_class(self, row, i): + if row.status_code in (row.STATUS_MISSING_KEY, + row.STATUS_PRODUCT_NOT_FOUND): + return 'warning' + + def configure_row_form(self, f): + super().configure_row_form(f) + + 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('category', self.render_category) + f.set_renderer('family', self.render_family) + f.set_renderer('reportcode', self.render_report) + + f.set_type('regular_cost', 'currency') + f.set_type('current_cost', 'currency') + f.set_type('regular_price', 'currency') + f.set_type('current_price', 'currency') + f.set_type('suggested_price', 'currency') + + # highlight red for SRP breaches + f.set_renderer('suggested_price', self.render_suggested_price) + + def render_suggested_price(self, row, field): + price = getattr(row, field) + if not price: + return "" + + text = "${:0,.2f}".format(price) + + if row.regular_price and row.suggested_price and ( + row.regular_price > row.suggested_price): + text = HTML.tag('span', style='color: red;', c=text) + + return text + + def get_execute_success_url(self, batch, result, **kwargs): + if kwargs['action'] == 'make_label_batch': + 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().get_execute_success_url(batch) + + def get_row_csv_fields(self): + fields = super().get_row_csv_fields() + + if 'vendor_uuid' in fields: + i = fields.index('vendor_uuid') + fields.insert(i + 1, 'vendor_id') + fields.insert(i + 2, 'vendor_abbreviation') + fields.insert(i + 3, 'vendor_name') + else: + fields.append('vendor_id') + fields.append('vendor_abbreviation') + fields.append('vendor_name') + + if 'category_uuid' in fields: + i = fields.index('category_uuid') + fields.insert(i + 1, 'category_code') + fields.insert(i + 2, 'category_name') + else: + fields.append('category_code') + fields.append('category_name') + + if 'family_uuid' in fields: + i = fields.index('family_uuid') + fields.insert(i + 1, 'family_code') + fields.insert(i + 2, 'family_name') + else: + fields.append('family_code') + fields.append('family_name') + + if 'reportcode_uuid' in fields: + i = fields.index('reportcode_uuid') + fields.insert(i + 1, 'report_code') + fields.insert(i + 2, 'report_name') + else: + fields.append('report_code') + fields.append('report_name') + + return fields + + def supplement_row_data(self, row, fields, data): + vendor = row.vendor + if 'vendor_id' in fields: + data['vendor_id'] = (vendor.id or '') if vendor else '' + if 'vendor_abbreviation' in fields: + data['vendor_abbreviation'] = (vendor.abbreviation or '') if vendor else '' + if 'vendor_name' in fields: + data['vendor_name'] = (vendor.name or '') if vendor else '' + + category = row.category + if 'category_code' in fields: + data['category_code'] = (category.code or '') if category else '' + if 'category_name' in fields: + data['category_name'] = (category.name or '') if category else '' + + family = row.family + if 'family_code' in fields: + data['family_code'] = (family.code or '') if family else '' + if 'family_name' in fields: + data['family_name'] = (family.name or '') if family else '' + + report = row.reportcode + if 'report_code' in fields: + data['report_code'] = (report.code or '') if report else '' + if 'report_name' in fields: + data['report_name'] = (report.name or '') if report else '' + + def get_row_csv_row(self, 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().get_row_xlsx_row(row, fields) + self.supplement_row_data(row, fields, xlrow) + return xlrow + + +def includeme(config): + ProductBatchView.defaults(config) diff --git a/tailbone/views/batch/vendorcatalog.py b/tailbone/views/batch/vendorcatalog.py new file mode 100644 index 00000000..ec8da979 --- /dev/null +++ b/tailbone/views/batch/vendorcatalog.py @@ -0,0 +1,445 @@ +# -*- 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 maintaining vendor catalogs +""" + +import logging + +from rattail.db import model + +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags + +from tailbone import forms +from tailbone.views.batch import FileBatchMasterView +from tailbone.diffs import Diff +from tailbone.db import Session + + +log = logging.getLogger(__name__) + + +class VendorCatalogView(FileBatchMasterView): + """ + Master view for vendor catalog batches. + """ + model_class = model.VendorCatalogBatch + model_row_class = model.VendorCatalogBatchRow + default_handler_spec = 'rattail.batch.vendorcatalog:VendorCatalogHandler' + route_prefix = 'vendorcatalogs' + url_prefix = '/vendors/catalogs' + template_prefix = '/batch/vendorcatalog' + 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 = [ + 'id', + 'vendor', + 'description', + 'filename', + 'rowcount', + 'created', + 'executed', + ] + + form_fields = [ + 'id', + 'filename', + 'parser_key', + 'vendor', + 'future', + 'effective', + 'cache_products', + 'params', + 'description', + 'notes', + 'created', + 'created_by', + 'rowcount', + 'executed', + 'executed_by', + ] + + row_grid_columns = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'vendor_code', + 'is_preferred_vendor', + 'old_unit_cost', + 'unit_cost', + 'unit_cost_diff', + 'unit_cost_diff_percent', + 'starts', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'item_entry', + 'product', + 'upc', + 'brand_name', + 'description', + 'size', + 'is_preferred_vendor', + 'suggested_retail', + 'starts', + 'ends', + 'discount_starts', + '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'): + 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) + 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) + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + + # filename + f.set_label('filename', "Catalog File") + + # parser_key + if self.creating: + if 'parser_key' not in f: + f.insert_after('filename', 'parser_key') + 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)) + else: + 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) + kwargs['parser_key'] = batch.parser_key + if batch.vendor: + kwargs['vendor'] = batch.vendor + elif batch.vendor_uuid: + kwargs['vendor_uuid'] = batch.vendor_uuid + if batch.vendor_id: + kwargs['vendor_id'] = batch.vendor_id + if batch.vendor_name: + kwargs['vendor_name'] = batch.vendor_name + 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.remove('starts') + + g.set_type('old_unit_cost', 'currency') + g.set_type('unit_cost', 'currency') + g.set_type('unit_cost_diff', 'currency') + + g.set_type('unit_cost_diff_percent', 'percent') + g.set_label('unit_cost_diff_percent', "Diff. %") + + g.set_label('is_preferred_vendor', "Pref. Vendor") + + g.set_label('upc', "UPC") + g.set_label('brand_name', "Brand") + g.set_label('old_unit_cost', "Old Cost") + 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' + if row.status_code in (row.STATUS_NEW_COST, + row.STATUS_UPDATE_COST, # TODO: deprecate/remove this one + row.STATUS_CHANGE_VENDOR_ITEM_CODE, + row.STATUS_CHANGE_CASE_SIZE, + row.STATUS_CHANGE_COST, + row.STATUS_CHANGE_PRODUCT): + return 'notice' + + def configure_row_form(self, f): + super(VendorCatalogView, self).configure_row_form(f) + 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) + + 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 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 new file mode 100644 index 00000000..4815d1f4 --- /dev/null +++ b/tailbone/views/batch/vendorinvoice.py @@ -0,0 +1,204 @@ +# -*- 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 maintaining vendor invoices +""" + +from rattail.db import model +from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser + +# import formalchemy +import colander +from deform import widget as dfwidget + +from tailbone.views.batch import FileBatchMasterView + + +class VendorInvoiceView(FileBatchMasterView): + """ + Master view for vendor invoice batches. + """ + model_class = model.VendorInvoiceBatch + model_row_class = model.VendorInvoiceBatchRow + default_handler_spec = 'rattail.batch.vendorinvoice:VendorInvoiceHandler' + route_prefix = 'vendorinvoices' + url_prefix = '/vendors/invoices' + + grid_columns = [ + 'id', + 'vendor', + 'invoice_date', + 'purchase_order_number', + 'filename', + 'rowcount', + 'created', + 'created_by', + 'executed', + ] + + form_fields = [ + 'id', + 'vendor', + 'filename', + 'parser_key', + 'purchase_order_number', + 'invoice_date', + 'rowcount', + 'created', + 'created_by', + 'executed', + 'executed_by', + ] + + row_grid_columns = [ + 'sequence', + 'upc', + 'brand_name', + 'description', + 'size', + 'vendor_code', + 'shipped_cases', + 'shipped_units', + 'unit_cost', + 'unit_cost_diff', + 'status_code', + ] + + def get_instance_title(self, batch): + return str(batch.vendor) + + def configure_grid(self, g): + super().configure_grid(g) + + # vendor + g.set_joiner('vendor', lambda q: q.join(model.Vendor)) + g.set_filter('vendor', model.Vendor.name, + default_active=True, + default_verb='contains') + g.set_sorter('vendor', model.Vendor.name) + g.set_link('vendor') + + # invoice_date + g.set_link('invoice_date') + + # purchase_order_number + g.set_link('purchase_order_number') + + # filename + g.set_link('filename') + + # created + g.set_link('created', False) + + # executed + g.set_link('executed', False) + + def configure_form(self, f): + super().configure_form(f) + + # vendor + if self.creating: + f.remove_field('vendor') + else: + f.set_readonly('vendor') + + # purchase_order_number + f.set_label('purchase_order_number', self.handler.po_number_title) + # f.set_validator('purchase_order_number', self.validate_po_number) + + # filename + f.set_label('filename', "Invoice File") + + # invoice_date + if self.creating: + f.remove_field('invoice_date') + else: + f.set_readonly('invoice_date') + + # parser_key + if self.creating: + parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) + parser_values = [(p.key, p.display) for p in parsers] + parser_values.insert(0, ('', "(please choose)")) + f.set_widget('parser_key', dfwidget.SelectWidget(values=parser_values)) + f.set_label('parser_key', "File Type") + else: + f.remove_field('parser_key') + + # TODO: this is not needed for now, and this whole thing should be merged + # with the Purchasing Batch system anyway... + # def validate_po_number(self, value, field): + # """ + # Let the invoice handler in effect determine if the user-provided + # purchase order number is valid. + # """ + # parser_key = field.parent.parser_key.value + # if not parser_key: + # raise formalchemy.ValidationError("Cannot validate PO number until File Type is chosen") + # parser = require_invoice_parser(parser_key) + # vendor = api.get_vendor(self.Session(), parser.vendor_key) + # try: + # self.handler.validate_po_number(value, vendor) + # except ValueError as error: + # raise formalchemy.ValidationError(unicode(error)) + + def get_batch_kwargs(self, batch): + kwargs = super().get_batch_kwargs(batch) + kwargs['parser_key'] = batch.parser_key + return kwargs + + def init_batch(self, batch): + 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 + batch.vendor = vendor + return True + + def configure_row_grid(self, g): + super().configure_row_grid(g) + g.set_label('upc', "UPC") + g.set_label('brand_name', "Brand") + g.set_label('shipped_cases', "Cases") + g.set_label('shipped_units', "Units") + + def row_grid_extra_class(self, row, i): + if row.status_code in (row.STATUS_NOT_IN_DB, + row.STATUS_COST_NOT_IN_DB, + row.STATUS_NO_CASE_QUANTITY): + return 'warning' + if row.status_code in (row.STATUS_NOT_IN_PURCHASE, + row.STATUS_NOT_IN_INVOICE, + row.STATUS_DIFFERS_FROM_PURCHASE, + row.STATUS_UNIT_COST_DIFFERS): + return 'notice' + +# TODO: deprecate / remove this +VendorInvoicesView = VendorInvoiceView + + +def includeme(config): + VendorInvoiceView.defaults(config) diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index bcbd7131..7afcc567 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,23 +24,18 @@ Views for Email Bounces """ -from __future__ import unicode_literals, absolute_import - import os import datetime 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 import grids -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView -class EmailBouncesView(MasterView): +class EmailBounceView(MasterView): """ Master view for email bounces. """ @@ -49,6 +44,7 @@ class EmailBouncesView(MasterView): url_prefix = '/email-bounces' creatable = False editable = False + downloadable = True labels = { 'config_key': "Source", @@ -65,28 +61,31 @@ class EmailBouncesView(MasterView): ] def __init__(self, request): - super(EmailBouncesView, 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(EmailBouncesView, self).configure_grid(g) + super().configure_grid(g) + model = self.model - g.joiners['processed_by'] = lambda q: q.outerjoin(model.User) + g.filters['config_key'].set_choices(self.handler_options) g.filters['config_key'].default_active = True g.filters['config_key'].default_verb = 'equal' - g.filters['config_key'].set_value_renderer(grids.filters.ChoiceValueRenderer(self.handler_options)) 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) - g.default_sortkey = 'bounced' - g.default_sortdir = 'desc' - g.set_label('config_key', "Source") + # 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") g.set_label('intended_recipient_address', "Intended For") @@ -94,7 +93,7 @@ class EmailBouncesView(MasterView): g.set_link('intended_recipient_address') def configure_form(self, f): - super(EmailBouncesView, 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) @@ -143,11 +142,17 @@ class EmailBouncesView(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. @@ -156,8 +161,9 @@ class EmailBouncesView(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*. @@ -166,22 +172,15 @@ class EmailBouncesView(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'] = str(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) @@ -199,15 +198,13 @@ class EmailBouncesView(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() + + EmailBounceView = kwargs.get('EmailBounceView', base['EmailBounceView']) + EmailBounceView.defaults(config) def includeme(config): - EmailBouncesView.defaults(config) + defaults(config) diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index dac064b2..109c80a7 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -28,43 +28,120 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView3 as MasterView, AutocompleteView +from tailbone.views import MasterView -class BrandsView(MasterView): +class BrandView(MasterView): """ Master view for the Brand class. """ model_class = model.Brand has_versions = True + bulk_deletable = True + results_downloadable = True + supports_autocomplete = True + + mergeable = True + merge_additive_fields = [ + 'product_count', + ] + merge_fields = merge_additive_fields + [ + 'uuid', + 'name', + ] grid_columns = [ 'name', + 'confirmed', ] form_fields = [ 'name', + 'confirmed', + ] + + has_rows = True + model_row_class = model.Product + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'upc', + 'description', + 'size', + 'department', + 'vendor', + 'regular_price', + 'current_price', ] def configure_grid(self, g): - super(BrandsView, self).configure_grid(g) + super(BrandView, self).configure_grid(g) + + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'name' + g.set_sort_defaults('name') g.set_link('name') + # confirmed + g.set_type('confirmed', 'boolean') -class BrandsAutocomplete(AutocompleteView): + def get_row_data(self, brand): + return self.Session.query(model.Product)\ + .filter(model.Product.brand == brand) - mapped_class = model.Brand - fieldname = 'name' + def get_parent(self, product): + return product.brand + + def configure_row_grid(self, g): + super(BrandView, self).configure_row_grid(g) + + app = self.get_rattail_app() + self.handler = app.get_products_handler() + g.set_renderer('regular_price', self.render_price) + g.set_renderer('current_price', self.render_price) + + g.set_sort_defaults('upc') + + def render_price(self, product, field): + if not product.not_for_sale: + price = product[field] + if price: + return self.handler.render_price(price) + + def row_view_action_url(self, product, i): + return self.request.route_url('products.view', uuid=product.uuid) + + def get_merge_data(self, brand): + product_count = self.Session.query(model.Product)\ + .filter(model.Product.brand == brand)\ + .count() + return { + 'uuid': brand.uuid, + 'name': brand.name, + 'product_count': product_count, + } + + def merge_objects(self, removing, keeping): + products = self.Session.query(model.Product)\ + .filter(model.Product.brand == removing)\ + .all() + for product in products: + product.brand = keeping + + self.Session.flush() + self.Session.delete(removing) + + +def defaults(config, **kwargs): + base = globals() + + BrandView = kwargs.get('BrandView', base['BrandView']) + BrandView.defaults(config) def includeme(config): - - # autocomplete - config.add_route('brands.autocomplete', '/brands/autocomplete') - config.add_view(BrandsAutocomplete, route_name='brands.autocomplete', - renderer='json', permission='brands.list') - - BrandsView.defaults(config) + defaults(config) diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index 7586d6cc..941257b8 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -28,10 +28,11 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView3 as MasterView +from tailbone import forms +from tailbone.views import MasterView -class CategoriesView(MasterView): +class CategoryView(MasterView): """ Master view for the Category class. """ @@ -39,30 +40,82 @@ class CategoriesView(MasterView): model_title_plural = "Categories" route_prefix = 'categories' has_versions = True + results_downloadable = True grid_columns = [ 'code', - 'number', 'name', 'department', ] form_fields = [ 'code', - 'number', 'name', 'department', ] def configure_grid(self, g): - super(CategoriesView, self).configure_grid(g) + super(CategoryView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'code' + + g.set_joiner('department', lambda q: q.outerjoin(model.Department)) + g.set_sorter('department', model.Department.name) + g.set_filter('department', model.Department.name) + + g.set_sort_defaults('code') g.set_link('code') - g.set_link('number') g.set_link('name') + def get_xlsx_fields(self): + fields = super(CategoryView, self).get_xlsx_fields() + fields.extend([ + 'department_number', + 'department_name', + ]) + return fields + + def get_xlsx_row(self, category, fields): + row = super(CategoryView, self).get_xlsx_row(category, fields) + dept = category.department + if dept: + row['department_number'] = dept.number + row['department_name'] = dept.name + else: + row['department_number'] = None + row['department_name'] = None + return row + + def configure_form(self, f): + super(CategoryView, self).configure_form(f) + + # department + if self.creating or self.editing: + if 'department' in f: + f.replace('department', 'department_uuid') + f.set_label('department_uuid', "Department") + dept_values = self.get_department_values() + dept_values.insert(0, ('', "(none)")) + f.set_widget('department_uuid', forms.widgets.JQuerySelectWidget(values=dept_values)) + else: + f.set_readonly('department') + + def get_department_values(self): + departments = self.Session.query(model.Department)\ + .order_by(model.Department.number) + return [(dept.uuid, "{} {}".format(dept.number, dept.name)) + for dept in departments] + +# TODO: deprecate / remove this +CategoriesView = CategoryView + + +def defaults(config, **kwargs): + base = globals() + + CategoryView = kwargs.get('CategoryView', base['CategoryView']) + CategoryView.defaults(config) + def includeme(config): - CategoriesView.defaults(config) + defaults(config) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 5bb43bd4..f4d98c05 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.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,54 +24,87 @@ Various common views """ -from __future__ import unicode_literals, absolute_import +import os +import warnings +from collections import OrderedDict -import six - -import rattail 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 -import formencode as fe -from pyramid import httpexceptions -from pyramid.response import Response -from pyramid_simpleform import Form - -import tailbone from tailbone import forms +from tailbone.forms.common import Feedback from tailbone.db import Session from tailbone.views import View - - -class Feedback(fe.Schema): - """ - Form schema for user feedback. - """ - allow_extra_fields = True - referrer = fe.validators.NotEmpty() - user = forms.validators.ValidUser() - user_name = fe.validators.NotEmpty() - message = fe.validators.NotEmpty() +from tailbone.util import set_app_theme +from tailbone.config import global_help_url 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, mobile=False): + def home(self, **kwargs): """ Home page view. """ - image_url = self.rattail_config.get( - 'tailbone', 'main_image_url', - default=self.request.static_url('tailbone:static/img/home_logo.png')) - return {'image_url': image_url} + app = self.get_rattail_app() + + # maybe auto-redirect anons to login + if not self.request.user: + 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.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') + + context = { + 'image_url': image_url, + 'index_title': app.get_node_title(), + 'help_url': global_help_url(self.rattail_config), + } + + 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): """ @@ -79,28 +112,40 @@ class CommonView(View): """ with open(self.robots_txt_path, 'rt') as f: content = f.read() - return Response(content_type=six.binary_type('text/plain'), body=content) + response = self.request.response + response.text = content + response.content_type = 'text/plain' + return response - def mobile_home(self): - """ - Home page view for mobile. - """ - return self.home(mobile=True) + 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. """ + app = self.get_rattail_app() return { - 'project_title': self.project_title, - 'project_version': self.project_version, + 'project_title': self.get_project_title(), + 'project_version': self.get_project_version(), 'packages': self.get_packages(), + 'index_title': app.get_node_title(), } def get_packages(self): @@ -109,22 +154,57 @@ 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): + """ + Simple view which can change user's visible UI theme, then redirect + user back to referring page. + """ + theme = self.request.params.get('theme') + if theme: + try: + set_app_theme(self.request, theme, session=Session()) + except Exception as error: + msg = "Failed to set theme: {}: {}".format(error.__class__.__name__, error) + self.request.session.flash(msg, 'error') + referrer = self.request.params.get('referrer') or self.request.get_referrer() + return self.redirect(referrer) + + def change_db_engine(self): + """ + Simple view which can change user's "current" database engine, of a + given type, then redirect back to referring page. + """ + engine_type = self.request.POST.get('engine_type') + if engine_type: + dbkey = self.request.POST.get('dbkey') + if dbkey: + self.request.session['tailbone.engines.{}.current'.format(engine_type)] = dbkey + if self.rattail_config.getbool('tailbone', 'engines.flash_after_change', default=True): + self.request.session.flash("Switched '{}' database to: {}".format(engine_type, dbkey)) + return self.redirect(self.request.get_referrer()) + def feedback(self): """ - Generic view to present/handle the user feedback form. + Generic view to handle the user feedback form. """ - form = Form(self.request, schema=Feedback) + app = self.get_rattail_app() + model = self.model + schema = Feedback().bind(session=Session()) + form = forms.Form(schema=schema, request=self.request) if form.validate(): - data = dict(form.data) + data = dict(form.validated) if data['user']: + data['user'] = Session.get(model.User, data['user']) data['user_url'] = self.request.route_url('users.view', uuid=data['user'].uuid) - send_email(self.rattail_config, 'user_feedback', data=data) + data['client_ip'] = self.request.client_addr + app.send_email('user_feedback', data=data) return {'ok': True} - return {'error': "Form did not validate!"} + dform = form.make_deform_form() + return {'error': str(dform.error)} def consume_batch_id(self): """ @@ -141,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) @@ -158,12 +307,17 @@ 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', '/') config.add_view(cls, attr='home', route_name='home', renderer='/home.mako') - config.add_route('mobile.home', '/mobile/') - config.add_view(cls, attr='mobile_home', route_name='mobile.home', renderer='/mobile/home.mako') # robots.txt config.add_route('robots.txt', '/robots.txt') @@ -172,12 +326,27 @@ class CommonView(View): # about config.add_route('about', '/about') config.add_view(cls, attr='about', route_name='about', renderer='/about.mako') - config.add_route('mobile.about', '/mobile/about') - config.add_view(cls, attr='about', route_name='mobile.about', renderer='/mobile/about.mako') + + # change db engine + 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', + permission='common.change_db_engine') + + # change theme + config.add_tailbone_permission('common', 'common.change_app_theme', + "Change global App Template Theme") + config.add_route('change_theme', '/change-theme', request_method='POST') + config.add_view(cls, attr='change_theme', route_name='change_theme') # feedback + config.add_tailbone_permission('common', 'common.feedback', + "Send user feedback (to admins) about the app") config.add_route('feedback', '/feedback', request_method='POST') - config.add_view(cls, attr='feedback', route_name='feedback', renderer='json') + config.add_view(cls, attr='feedback', route_name='feedback', + renderer='json', permission='common.feedback') # consume batch ID config.add_tailbone_permission('common', 'common.consume_batch_id', @@ -189,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 ba3d1f9b..88b2519f 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.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,27 +24,26 @@ Base View Class """ -from __future__ import unicode_literals, absolute_import - import os -from rattail.db import model -from rattail.util import progress_loop - from pyramid import httpexceptions from pyramid.renderers import render_to_response from pyramid.response import FileResponse from tailbone.db import Session from tailbone.auth import logout_user +from tailbone.progress import SessionProgress +from tailbone.config import protected_usernames -class View(object): +class View: """ Base class for all class-based views. """ + # quickie (search) + expose_quickie_search = False - def __init__(self, request): + def __init__(self, request, context=None): self.request = request # if user becomes inactive while logged in, log them out @@ -59,7 +58,10 @@ class View(object): config = self.rattail_config if config: - self.enum = config.get_enum() + self.config = config + self.app = self.config.get_app() + self.model = self.app.model + self.enum = self.app.enum @property def rattail_config(self): @@ -68,6 +70,20 @@ class View(object): """ return getattr(self.request, 'rattail_config', None) + def get_rattail_app(self): + """ + Returns the Rattail ``AppHandler`` instance, creating it if necessary. + """ + if not hasattr(self, 'rattail_app'): + self.rattail_app = self.rattail_config.get_app() + return self.rattail_app + + def forbidden(self): + """ + Convenience method, to raise a HTTP 403 Forbidden exception. + """ + return httpexceptions.HTTPForbidden() + def notfound(self): return httpexceptions.HTTPNotFound() @@ -76,10 +92,25 @@ class View(object): 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): + """ + This logic will consult the settings for a list of "protected" + usernames, which should require root privileges to edit. If the given + ``user`` object is represented in this list, it is considered to be + protected and this method will return ``True``; otherwise it returns + ``False``. + """ + if not hasattr(self, 'protected_usernames'): + self.protected_usernames = protected_usernames(self.rattail_config) + if self.protected_usernames and user.username in self.protected_usernames: + return True + return False def redirect(self, url, **kwargs): """ @@ -88,7 +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, **kwargs): + """ + Create and return a :class:`tailbone.progress.SessionProgress` + instance, with the given key. + """ + return SessionProgress(self.request, key, **kwargs) # TODO: this signature seems wonky def render_progress(self, progress, kwargs, template=None): @@ -98,16 +137,45 @@ class View(object): if not template: template = '/progress.mako' kwargs['progress'] = progress + kwargs.setdefault('can_cancel', True) return render_to_response(template, kwargs, request=self.request) - def file_response(self, path): + def json_response(self, data): + """ + Convenience method to return a JSON response. + """ + return render_to_response('json', data, + request=self.request) + + def file_response(self, path, filename=None, attachment=True): """ Returns a generic FileResponse from the given path """ if not os.path.exists(path): return self.notfound() response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = str(os.path.getsize(path)) - filename = os.path.basename(path).encode('ascii', 'replace') - response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(filename) + response.content_length = os.path.getsize(path) + if attachment: + if not filename: + filename = os.path.basename(path) + 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): + app = self.get_rattail_app() + return app.make_object( + url=self.get_quickie_url(), + perm=self.get_quickie_perm(), + placeholder=self.get_quickie_placeholder()) + + def get_quickie_url(self): + raise NotImplementedError + + def get_quickie_perm(self): + raise NotImplementedError + + def get_quickie_placeholder(self): + pass diff --git a/tailbone/views/customergroups.py b/tailbone/views/customergroups.py index faadb856..98cea8e0 100644 --- a/tailbone/views/customergroups.py +++ b/tailbone/views/customergroups.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -29,10 +29,10 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model from tailbone.db import Session -from tailbone.views import MasterView3 as MasterView +from tailbone.views import MasterView -class CustomerGroupsView(MasterView): +class CustomerGroupView(MasterView): """ Master view for the CustomerGroup class. """ @@ -54,10 +54,10 @@ class CustomerGroupsView(MasterView): ] def configure_grid(self, g): - super(CustomerGroupsView, self).configure_grid(g) + super(CustomerGroupView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'name' + g.set_sort_defaults('name') g.set_link('id') g.set_link('name') @@ -76,6 +76,16 @@ class CustomerGroupsView(MasterView): cls._defaults(config) +# TODO: deprecate / remove this +CustomerGroupsView = CustomerGroupView + + +def defaults(config, **kwargs): + base = globals() + + CustomerGroupView = kwargs.get('CustomerGroupView', base['CustomerGroupView']) + CustomerGroupView.defaults(config) + def includeme(config): - CustomerGroupsView.defaults(config) + defaults(config) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 0a4b8649..7e49ccef 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.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,85 +24,233 @@ Customer Views """ -from __future__ import unicode_literals, absolute_import - -import re +from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm -import formalchemy as fa +import colander from pyramid.httpexceptions import HTTPNotFound +from webhelpers2.html import HTML, tags -from tailbone import forms +from tailbone import grids from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView, AutocompleteView +from tailbone.views import MasterView -from rattail.db import model +from rattail.db.model import Customer, CustomerShopper, PendingCustomer -class CustomersView(MasterView): +class CustomerView(MasterView): """ Master view for the Customer class. """ - model_class = model.Customer + model_class = Customer + is_contact = True has_versions = True - supports_mobile = 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", + 'active_in_pos': "Active in POS", + 'active_in_pos_sticky': "Always Active in POS", + } + grid_columns = [ - 'id', - 'number', + '_customer_key_', 'name', 'phone', 'email', ] + form_fields = [ + '_customer_key_', + 'name', + 'account_holder', + 'default_phone', + 'default_address', + 'address_street', + 'address_street2', + 'address_city', + 'address_state', + 'address_zipcode', + 'default_email', + 'email_preference', + '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(CustomersView, self).configure_grid(g) + super().configure_grid(g) + app = self.get_rattail_app() + model = self.model + route_prefix = self.get_route_prefix() - g.joiners['email'] = lambda q: q.outerjoin(model.CustomerEmailAddress, sa.and_( - model.CustomerEmailAddress.parent_uuid == model.Customer.uuid, - model.CustomerEmailAddress.preference == 1)) - g.joiners['phone'] = lambda q: q.outerjoin(model.CustomerPhoneNumber, sa.and_( - model.CustomerPhoneNumber.parent_uuid == model.Customer.uuid, - model.CustomerPhoneNumber.preference == 1)) - - g.filters['email'] = g.make_filter('email', model.CustomerEmailAddress.address, - label="Email Address") - g.filters['phone'] = g.make_filter('phone', model.CustomerPhoneNumber.number, - label="Phone Number") - - # TODO - # name=self.filter_ilike_and_soundex(model.Customer.name), + # 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.sorters['email'] = lambda q, d: q.order_by(getattr(model.CustomerEmailAddress.address, d)()) - g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.CustomerPhoneNumber.number, d)()) - - g.default_sortkey = 'name' - - g.set_label('id', "ID") + # 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.set_sorter('phone', model.CustomerPhoneNumber.number) + g.set_filter('phone', model.CustomerPhoneNumber.number, + # label="Phone Number", + factory=grids.filters.AlchemyPhoneNumberFilter) + + # 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.set_sorter('email', model.CustomerEmailAddress.address) + g.set_filter('email', model.CustomerEmailAddress.address)#, label="Email Address") + + # email_preference + g.set_enum('email_preference', self.enum.EMAIL_PREFERENCE) + + # account_holder_*_name + g.set_filter('account_holder_first_name', model.Person.first_name) + g.set_filter('account_holder_last_name', model.Person.last_name) + + # person + g.set_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 get_mobile_data(self, session=None): - # TODO: hacky! - return self.get_data(session=session).order_by(model.Customer.name) + 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(CustomersView, 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 @@ -113,113 +261,638 @@ class CustomersView(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 _preconfigure_fieldset(self, fs): - fs.id.set(label="ID") - fs.append(forms.fields.DefaultPhoneField('default_phone', label="Phone Number")) - fs.append(forms.fields.DefaultEmailField('default_email', label="Email Address")) - fs.email_preference.set(renderer=forms.EnumFieldRenderer(self.enum.EMAIL_PREFERENCE), - attrs={'auto-enhance': 'true'}) - fs.email_preference._null_option = ("(no preference)", '') - fs.append(forms.AssociationProxyField('people', renderer=forms.renderers.PeopleFieldRenderer, - readonly=True)) - fs.active_in_pos.set(label="Active in POS") - fs.active_in_pos_sticky.set(label="Always Active in POS") + def configure_form(self, f): + super().configure_form(f) + customer = f.model_instance + permission_prefix = self.get_permission_prefix() - def configure_fieldset(self, fs): - include = [ - fs.id, - fs.name, - fs.default_phone, - fs.default_email, - fs.email_preference, - fs.active_in_pos, - fs.active_in_pos_sticky, - ] - if not self.creating: - include.extend([ - fs.people, - ]) - fs.configure(include=include) + # account_holder + if self.creating: + f.remove_field('account_holder') + else: + f.set_readonly('account_holder') + f.set_renderer('account_holder', self.render_person) - def configure_mobile_fieldset(self, fs): - fs.configure( - include=[ - fs.email, - fs.phone, - ]) + # 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) + + # default_address + if self.creating or self.editing: + f.remove_field('default_address') + else: + f.set_renderer('default_address', self.render_default_address) + f.set_readonly('default_address') + + # address_* + if not (self.creating or self.editing): + f.remove_fields('address_street', + 'address_street2', + 'address_city', + 'address_state', + 'address_zipcode') + elif self.editing and customer.addresses: + addr = customer.addresses[0] + f.set_default('address_street', addr.street) + f.set_default('address_street2', addr.street2) + f.set_default('address_city', addr.city) + 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)")) + f.widgets['email_preference'].values = preferences + + # person + if self.creating: + f.remove_field('person') + else: + f.set_readonly('person') + f.set_renderer('person', self.form_render_person) + + # 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') + + # groups + if self.creating: + f.remove_field('groups') + else: + f.set_renderer('groups', self.render_groups) + f.set_readonly('groups') + + # active_in_pos* + if not self.get_expose_active_in_pos(): + f.remove('active_in_pos', + 'active_in_pos_sticky') + + # members + if self.creating: + f.remove_field('members') + else: + f.set_renderer('members', self.render_members) + f.set_readonly('members') + + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + customer = kwargs['instance'] + + 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 + + kwargs['expose_people'] = self.should_expose_people() + if kwargs['expose_people']: + people = [] + for person in customer.people: + data = { + 'uuid': person.uuid, + 'full_name': person.display_name, + 'first_name': person.first_name, + 'last_name': person.last_name, + '_action_url_view': self.request.route_url('people.view', + 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', + uuid=customer.uuid, + person_uuid=person.uuid) + 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: + customer = self.get_instance() + query = query.filter(model.Customer.uuid != customer.uuid) + if query.count(): + raise colander.Invalid(node, "Customer ID must be unique") + + def render_default_address(self, customer, field): + if customer.addresses: + return str(customer.addresses[0]) + + def grid_render_person(self, customer, field): + person = getattr(customer, field) + if not person: + return "" + return str(person) + + def form_render_person(self, customer, field): + person = getattr(customer, field) + if not person: + return "" + + text = str(person) + url = self.request.route_url('people.view', uuid=person.uuid) + return tags.link_to(text, url) + + 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( + 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', + 'first_name', + 'last_name', + ], + sortable=True, + sorters={'full_name': True, 'first_name': True, 'last_name': True}, + ) + + if self.request.has_perm('people.view'): + g.actions.append(self.make_action('view', icon='eye')) + if self.request.has_perm('people.edit'): + g.actions.append(self.make_action('edit', icon='edit')) + if self.people_detachable and self.has_perm('detach_person'): + 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_table_element(data_prop='peopleData')) + + def render_groups(self, customer, field): + groups = customer.groups + if not groups: + return "" + items = [] + for group in groups: + text = "({}) {}".format(group.id, group.name) + url = self.request.route_url('customergroups.view', uuid=group.uuid) + items.append(HTML.tag('li', tags.link_to(text, url))) + return HTML.tag('ul', HTML.literal('').join(items)) + + def render_members(self, customer, field): + members = customer.members + if not members: + return "" + items = [] + for member in members: + 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.get(model.Person, self.request.matchdict['person_uuid']) + if not person: + return self.notfound() + + if person in customer.people: + customer.people.remove(person) + else: + self.request.session.flash("No change; person \"{}\" not attached to customer \"{}\"".format( + person, customer)) + + 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) + cls._customer_defaults(config) -def unique_id(value, field): - customer = field.parent.model - query = Session.query(model.Customer).filter(model.Customer.id == value) - if customer.uuid: - query = query.filter(model.Customer.uuid != customer.uuid) - if query.count(): - raise fa.ValidationError("Customer ID must be unique") + @classmethod + def _customer_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_key = cls.get_model_key() + model_title = cls.get_model_title() + + # detach person + if cls.people_detachable: + config.add_tailbone_permission(permission_prefix, + '{}.detach_person'.format(permission_prefix), + "Detach a Person from a {}".format(model_title)) + # TODO: this should require POST! + config.add_route('{}.detach_person'.format(route_prefix), + '{}/detach-person/{{person_uuid}}'.format(instance_url_prefix), + # request_method='POST', + ) + config.add_view(cls, attr='detach_person', + route_name='{}.detach_person'.format(route_prefix), + permission='{}.detach_person'.format(permission_prefix)) -class CustomerNameAutocomplete(AutocompleteView): +class CustomerShopperView(MasterView): """ - Autocomplete view which operates on customer name. + Master view for the CustomerShopper class. """ - mapped_class = model.Customer - fieldname = 'name' + 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 CustomerPhoneAutocomplete(AutocompleteView): +class PendingCustomerView(MasterView): """ - Autocomplete view which operates on customer phone number. - - .. note:: - As currently implemented, this view will only work with a PostgreSQL - database. It normalizes the user's search term and the database values - to numeric digits only (i.e. removes special characters from each) in - order to be able to perform smarter matching. However normalizing the - database value currently uses the PG SQL ``regexp_replace()`` function. + Master view for the Pending Customer class. """ - invalid_pattern = re.compile(r'\D') + model_class = PendingCustomer + route_prefix = 'pending_customers' + url_prefix = '/customers/pending' - def prepare_term(self, term): - return self.invalid_pattern.sub('', term) + labels = { + 'id': "ID", + 'status_code': "Status", + } - def query(self, term): - return Session.query(model.CustomerPhoneNumber)\ - .filter(sa.func.regexp_replace(model.CustomerPhoneNumber.number, r'\D', '', 'g').like('%{0}%'.format(term)))\ - .order_by(model.CustomerPhoneNumber.number)\ - .options(orm.joinedload(model.CustomerPhoneNumber.customer)) + grid_columns = [ + 'id', + 'display_name', + 'first_name', + 'last_name', + 'phone_number', + 'email_address', + 'status_code', + ] - def display(self, phone): - return "{0} {1}".format(phone.number, phone.customer) + form_fields = [ + 'id', + 'display_name', + 'first_name', + 'middle_name', + 'last_name', + 'phone_number', + 'phone_type', + 'email_address', + 'email_type', + 'address_street', + 'address_street2', + 'address_city', + 'address_state', + 'address_zipcode', + 'address_type', + 'status_code', + 'created', + 'user', + ] - def value(self, phone): - return phone.customer.uuid + def configure_grid(self, 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().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): +# customer = field.parent.model +# query = Session.query(model.Customer).filter(model.Customer.id == value) +# if customer.uuid: +# query = query.filter(model.Customer.uuid != customer.uuid) +# if query.count(): +# raise fa.ValidationError("Customer ID must be unique") + + +# 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(Customer).filter(Customer.id == value) + if customers.count(): + raise colander.Invalid(node, "Customer ID must be unique") 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 { @@ -229,19 +902,27 @@ def customer_info(request): } -def includeme(config): +def defaults(config, **kwargs): + base = globals() - # autocomplete - config.add_route('customers.autocomplete', '/customers/autocomplete') - config.add_view(CustomerNameAutocomplete, route_name='customers.autocomplete', - renderer='json', permission='customers.list') - config.add_route('customers.autocomplete.phone', '/customers/autocomplete/phone') - config.add_view(CustomerPhoneAutocomplete, route_name='customers.autocomplete.phone', - renderer='json', permission='customers.list') - - # 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') - CustomersView.defaults(config) + 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/__init__.py b/tailbone/views/custorders/__init__.py index d2b4c5ed..78a3d3ab 100644 --- a/tailbone/views/custorders/__init__.py +++ b/tailbone/views/custorders/__init__.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -28,5 +28,6 @@ from __future__ import unicode_literals, absolute_import def includeme(config): + config.include('tailbone.views.custorders.creating') config.include('tailbone.views.custorders.orders') config.include('tailbone.views.custorders.items') diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py new file mode 100644 index 00000000..fa0df901 --- /dev/null +++ b/tailbone/views/custorders/batch.py @@ -0,0 +1,232 @@ +# -*- 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 customer order batch views +""" + +from rattail.db.model import CustomerOrderBatch, CustomerOrderBatchRow + +import colander +from webhelpers2.html import tags + +from tailbone import forms +from tailbone.views.batch import BatchMasterView + + +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 = CustomerOrderBatch + model_row_class = CustomerOrderBatchRow + default_handler_spec = 'rattail.batch.custorder:CustomerOrderBatchHandler' + + grid_columns = [ + 'id', + 'contact_name', + 'rowcount', + 'total_price', + 'created', + 'created_by', + 'executed', + 'executed_by', + ] + + form_fields = [ + 'id', + 'store', + 'customer', + 'person', + 'pending_customer', + 'contact_name', + 'phone_number', + 'email_address', + 'params', + 'created', + 'created_by', + 'rowcount', + 'total_price', + ] + + row_labels = { + 'product_brand': "Brand", + 'product_description': "Description", + 'product_size': "Size", + 'order_uom': "Order UOM", + } + + row_grid_columns = [ + 'sequence', + '_product_key_', + 'product_brand', + 'product_description', + 'product_size', + 'order_quantity', + 'order_uom', + 'case_quantity', + 'total_price', + '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().configure_grid(g) + + g.set_type('total_price', 'currency') + + g.set_link('contact_name') + g.set_link('created') + g.set_link('created_by') + + def configure_form(self, f): + super().configure_form(f) + order = f.model_instance + model = self.model + + # readonly fields + f.set_readonly('rows') + f.set_readonly('status_code') + + f.set_renderer('store', self.render_store) + + # customer + if 'customer' in f.fields and self.editing: + f.replace('customer', 'customer_uuid') + f.set_node('customer_uuid', colander.String(), missing=colander.null) + customer_display = "" + if self.request.method == 'POST': + if self.request.POST.get('customer_uuid'): + customer = self.Session.get(model.Customer, + self.request.POST['customer_uuid']) + if customer: + customer_display = str(customer) + elif self.editing: + 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)) + f.set_label('customer_uuid', "Customer") + else: + f.set_renderer('customer', self.render_customer) + + # person + if 'person' in f.fields and self.editing: + f.replace('person', 'person_uuid') + f.set_node('person_uuid', colander.String(), missing=colander.null) + person_display = "" + if self.request.method == 'POST': + if self.request.POST.get('person_uuid'): + person = self.Session.get(model.Person, + self.request.POST['person_uuid']) + if person: + person_display = str(person) + elif self.editing: + 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)) + f.set_label('person_uuid', "Person") + else: + f.set_renderer('person', self.render_person) + + # pending_customer + f.set_renderer('pending_customer', self.render_pending_customer) + + f.set_type('total_price', 'currency') + + def render_pending_customer(self, batch, field): + pending = batch.pending_customer + if not pending: + return + 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().configure_row_grid(g) + + g.set_type('case_quantity', 'quantity') + g.set_type('cases_ordered', 'quantity') + g.set_type('units_ordered', 'quantity') + g.set_type('order_quantity', 'quantity') + g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) + g.set_type('unit_price', 'currency') + g.set_type('total_price', 'currency') + + g.set_link('product_upc') + g.set_link('product_description') + + def configure_row_form(self, 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') + f.set_type('units_ordered', 'quantity') + f.set_type('order_quantity', 'quantity') + 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 new file mode 100644 index 00000000..cbdf6d5e --- /dev/null +++ b/tailbone/views/custorders/creating.py @@ -0,0 +1,56 @@ +# -*- 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/>. +# +################################################################################ +""" +Views for 'creating' customer order batches + +Note that this provides only the "direct" or "raw" table views for these +batches. This does *not* provide a way to create a new batch; you should see +:meth:`tailbone.views.custorders.orders.CustomerOrdersView.create()` for that +logic. +""" + +from __future__ import unicode_literals, absolute_import + +from tailbone.views.custorders.batch import CustomerOrderBatchView + + +class CreateCustomerOrderBatchView(CustomerOrderBatchView): + """ + Master view for "creating customer order" batches. + """ + route_prefix = 'new_custorders' + url_prefix = '/new-customer-orders' + model_title = "New Customer Order Batch" + model_title_plural = "New Customer Order Batches" + creatable = False + + +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 2bf27b27..e7edf3aa 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.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,145 +24,666 @@ Customer order item views """ -from __future__ import unicode_literals, absolute_import - import datetime from sqlalchemy import orm -from rattail.db import model -from rattail.time import localtime +from rattail.db.model import CustomerOrderItem -import formalchemy as fa +from webhelpers2.html import HTML, tags -from tailbone import forms -from tailbone.views import MasterView2 as MasterView -from tailbone.util import raw_datetime +from tailbone.views import MasterView +from tailbone.util import raw_datetime, csrf_token -class CustomerOrderItemsView(MasterView): +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 editable = False deletable = False + labels = { + 'order': "Customer Order", + 'order_id': "Order ID", + 'order_uom': "Order UOM", + 'status_code': "Status", + } + grid_columns = [ + 'order_id', 'person', + '_product_key_', + 'product_brand', + 'product_description', + 'product_size', + 'department_name', + 'case_quantity', + 'order_quantity', + 'order_uom', + 'total_price', + 'order_created', + 'status_code', + 'flagged', + ] + + form_fields = [ + 'order', + 'customer', + 'person', + 'sequence', + '_product_key_', + 'product', + 'pending_product', 'product_brand', 'product_description', 'product_size', 'case_quantity', - 'cases_ordered', - 'units_ordered', - 'order_created', + 'order_quantity', + 'order_uom', + 'unit_price', + 'total_price', + 'special_order', + 'price_needs_confirmation', + 'paid_amount', + 'payment_transaction_number', 'status_code', + 'flagged', + 'contact_attempts', + 'last_contacted', + 'events', ] - 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', - ] + 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(CustomerOrderItemsView, 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') + + # 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.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_filter('person', model.Person.display_name, + default_active=True, default_verb='contains') - g.default_sortkey = 'order_created' - g.default_sortdir = 'desc' + # 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") + + # "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') - g.set_type('total_price', 'currency') - g.set_renderer('person', self.render_person) + # 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') - g.set_label('person', "Person Name") - g.set_label('product_brand', "Brand") - g.set_label('product_description', "Description") - g.set_label('product_size', "Size") - g.set_label('status_code', "Status") + # status_code + g.set_renderer('status_code', self.render_status_code_column) - def render_person(self, item, column): - return item.order.person + # 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 + + def render_person_text(self, item, field): + person = item.order.person + if 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 _preconfigure_fieldset(self, fs): - fs.order.set(renderer=forms.renderers.CustomerOrderFieldRenderer) - fs.product.set(renderer=forms.renderers.ProductFieldRenderer) - fs.product_unit_of_measure.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.UNIT_OF_MEASURE)) - fs.case_quantity.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases_ordered.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_ordered.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.unit_price.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.total_price.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.paid_amount.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.status_code.set(label="Status") - fs.append(fa.Field('person', value=lambda i: i.order.person, - renderer=forms.renderers.PersonFieldRenderer)) + 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_fieldset(self, fs): - fs.configure( - include=[ - fs.person, - fs.product, - fs.product_brand, - fs.product_description, - fs.product_size, - fs.case_quantity, - fs.cases_ordered, - fs.units_ordered, - fs.unit_price, - fs.total_price, - fs.paid_amount, - fs.status_code, - ]) + def configure_form(self, f): + super().configure_form(f) + item = f.model_instance - def get_row_data(self, item): - return self.Session.query(model.CustomerOrderItemEvent)\ - .filter(model.CustomerOrderItemEvent.item == item)\ - .order_by(model.CustomerOrderItemEvent.occurred, - model.CustomerOrderItemEvent.type_code) + # order + f.set_renderer('order', self.render_order) - def configure_row_grid(self, g): - super(CustomerOrderItemsView, self).configure_row_grid(g) - g.set_label('occurred', "When") - g.set_label('type_code', "What") # TODO: enum renderer - g.set_label('user', "Who") - g.set_label('note', "Notes") + # contact + if self.batch_handler.new_order_requires_customer(): + f.remove('person') + else: + f.remove('customer') + + # product key + key = self.get_product_key_field() + f.set_renderer(key, lambda item, field: getattr(item, f'product_{key}')) + + # (pending) product + f.set_renderer('product', self.render_product) + f.set_renderer('pending_product', self.render_pending_product) + 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('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) + + # 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 + f.set_renderer('person', self.render_person) + + # status_code + f.set_renderer('status_code', self.render_status_code) + + # 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): + 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 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', + **{'@click': "$emit('change-status')"}) + items.append(button) + + left = HTML.tag('div', class_='level-left', c=items) + outer = HTML.tag('div', class_='level', c=[left]) + return outer + + 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( + self.request, + key=f'{route_prefix}.events', + data=[], + columns=[ + 'occurred', + 'type_code', + 'user', + 'note', + ], + labels={ + 'occurred': "When", + 'type_code': "What", + 'user': "Who", + }, + ) + + table = HTML.literal( + g.render_table_element(data_prop='eventsData')) + elements = [table] + + if self.has_perm('add_note'): + button = HTML.tag('b-button', type='is-primary', c="Add Note", + class_='is-pulled-right', + icon_pack='fas', icon_left='plus', + **{'@click': "$emit('add-note')"}) + button_wrapper = HTML.tag('div', c=[button], + style='margin-top: 0.5rem;') + elements.append(button_wrapper) + + return HTML.tag('div', + style='display: flex; flex-direction: column;', + c=elements) + + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + model = self.model + app = self.get_rattail_app() + item = kwargs['instance'] + + # fetch events for current item + kwargs['events_data'] = self.get_context_events(item) + + # fetch "other" order items, siblings of current one + order = item.order + other_items = self.Session.query(model.CustomerOrderItem)\ + .filter(model.CustomerOrderItem.order == order)\ + .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 = 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], + 'department_name': other.department_name, + 'product_barcode': other.product_upc.pretty() if other.product_upc else None, + 'unit_price': app.render_currency(other.unit_price), + '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_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 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)) + + # validate new status + new_status_code = int(self.request.POST['new_status_code']) + if new_status_code not in self.enum.CUSTORDER_ITEM_STATUS: + self.request.session.flash("Invalid status code", 'error') + return redirect + + # locate order items to which new status will be applied + order_items = [order_item] + uuids = self.request.POST['uuids'] + if uuids: + for uuid in uuids.split(','): + item = self.Session.get(model.CustomerOrderItem, uuid) + if item: + order_items.append(item) + + # locate user responsible for change + user = self.request.user + + # maybe grab extra user-provided note to attach + extra_note = self.request.POST.get('note') + + # apply new status to order item(s) + for item in order_items: + if item.status_code != new_status_code: + + # attach event + note = "status changed from \"{}\" to \"{}\"".format( + self.enum.CUSTORDER_ITEM_STATUS[item.status_code], + self.enum.CUSTORDER_ITEM_STATUS[new_status_code]) + if extra_note: + note = "{} - NOTE: {}".format(note, extra_note) + item.events.append(model.CustomerOrderItemEvent( + type_code=self.enum.CUSTORDER_ITEM_EVENT_STATUS_CHANGE, + user=user, note=note)) + + # 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])) + return redirect + + def add_note(self): + """ + View for adding a new note to current order item, optinally + also adding it to all other items under the parent order. + """ + item = self.get_instance() + data = self.request.json_body + + self.custorder_handler.add_note(item, data['note'], self.request.user, + apply_all=data['apply_all'] == True) + + self.Session.flush() + 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 = 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 = str(person) + url = self.request.route_url('people.view', uuid=person.uuid) + return tags.link_to(text, url) + + @classmethod + def defaults(cls, config): + cls._order_item_defaults(config) + cls._defaults(config) + + @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), + "Change status for 1 or more {}".format(model_title_plural)) + config.add_route('{}.change_status'.format(route_prefix), + '{}/change-status'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='change_status', + 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), + "Add arbitrary notes for {}".format(model_title_plural)) + config.add_route('{}.add_note'.format(route_prefix), + '{}/add-note'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='add_note', + route_name='{}.add_note'.format(route_prefix), + renderer='json', + permission='{}.add_note'.format(permission_prefix)) + + +# TODO: deprecate / remove this +CustomerOrderItemsView = CustomerOrderItemView + + +def defaults(config, **kwargs): + base = globals() + + CustomerOrderItemView = kwargs.get('CustomerOrderItemView', base['CustomerOrderItemView']) + CustomerOrderItemView.defaults(config) def includeme(config): - CustomerOrderItemsView.defaults(config) + defaults(config) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index cc57c7b2..b1a9831a 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.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,81 +24,1196 @@ Customer Order Views """ -from __future__ import unicode_literals, absolute_import +import decimal +import logging from sqlalchemy import orm -from rattail.db import model +from rattail.db.model import CustomerOrder, CustomerOrderItem +from rattail.util import simple_error +from rattail.batch import get_batch_handler -from tailbone import forms -from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView +from webhelpers2.html import tags, HTML + +from tailbone.views import MasterView -class CustomerOrdersView(MasterView): +log = logging.getLogger(__name__) + + +class CustomerOrderView(MasterView): """ Master view for customer orders """ - model_class = model.CustomerOrder + model_class = CustomerOrder route_prefix = 'custorders' - creatable = False editable = False - deletable = False + configurable = True + + labels = { + 'id': "Order ID", + 'status_code': "Status", + } grid_columns = [ 'id', 'customer', 'person', - 'created', 'status_code', + 'created', + 'created_by', ] + form_fields = [ + 'id', + 'store', + 'customer', + 'person', + 'pending_customer', + 'phone_number', + 'email_address', + 'total_price', + 'status_code', + 'created', + 'created_by', + ] + + has_rows = True + model_row_class = CustomerOrderItem + rows_viewable = False + + row_labels = { + 'order_uom': "Order UOM", + } + + 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(CustomerOrdersView, 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') - g.default_sortkey = 'created' - g.default_sortdir = 'desc' + # status_code + g.set_enum('status_code', self.enum.CUSTORDER_STATUS) - # TODO: enum choices renderer + # created + g.set_sort_defaults('created', 'desc') + + def get_instance_title(self, order): + return f"#{order.id} for {order.customer or order.person}" + + def configure_form(self, 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') + + f.set_enum('status_code', self.enum.CUSTORDER_STATUS) + + f.set_readonly('created') + + 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 = str(person) + url = self.request.route_url('people.view', uuid=person.uuid) + return tags.link_to(text, url) + + def render_pending_customer(self, batch, field): + pending = batch.pending_customer + if not pending: + return + text = str(pending) + url = self.request.route_url('pending_customers.view', uuid=pending.uuid) + return tags.link_to(text, url, + class_='has-background-warning') + + def get_row_data(self, order): + model = self.app.model + return self.Session.query(model.CustomerOrderItem)\ + .filter(model.CustomerOrderItem.order == order) + + def get_parent(self, item): + return item.order + + def make_row_grid_kwargs(self, **kwargs): + kwargs = super().make_row_grid_kwargs(**kwargs) + + 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 + + def row_view_action_url(self, item, i): + if self.request.has_perm('custorders.items.view'): + return self.request.route_url('custorders.items.view', uuid=item.uuid) + + def configure_row_grid(self, 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') + + 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_renderer('status_code', self.render_row_status_code) + + g.set_label('sequence', "Seq.") + g.filters['sequence'].label = "Sequence" + g.set_label('product_brand', "Brand") + g.set_label('product_description', "Description") + g.set_label('product_size', "Size") g.set_label('status_code', "Status") - g.set_label('id', "ID") - def _preconfigure_fieldset(self, fs): - fs.customer.set(options=[]) - fs.id.set(label="ID", readonly=True) - fs.person.set(renderer=forms.renderers.PersonFieldRenderer) - fs.created.set(readonly=True) - fs.status_code.set(label="Status") + g.set_sort_defaults('sequence') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id, - fs.customer, - fs.person, - fs.created, - fs.status_code, - ]) + 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): + app = self.get_rattail_app() + return app.get_batch_handler( + 'custorder', + default='rattail.batch.custorder:CustomerOrderBatchHandler') + + def create(self, form=None, template='create'): + """ + View for creating a new customer order. Note that it does so by way of + maintaining a "new customer order" batch, until the user finally + submits the order, at which point the batch is converted to a proper + order. + """ + app = self.get_rattail_app() + # TODO: deprecate / remove this + self.handler = self.batch_handler + batch = self.get_current_batch() + + if self.request.method == 'POST': + + # first we check for traditional form post + action = self.request.POST.get('action') + post_actions = [ + 'start_over_entirely', + 'delete_batch', + ] + if action in post_actions: + return getattr(self, action)(batch) + + # okay then, we'll assume newer JSON-style post params + data = dict(self.request.json_body) + action = data.get('action') + json_actions = [ + 'assign_contact', + 'unassign_contact', + 'update_phone_number', + 'update_email_address', + 'update_pending_customer', + 'get_customer_info', + # 'set_customer_data', + 'get_product_info', + 'get_past_items', + 'add_item', + 'update_item', + 'delete_item', + 'submit_new_order', + ] + if action in json_actions: + result = getattr(self, action)(batch, data) + return self.json_response(result) + + items = [self.normalize_row(row) + for row in batch.active_rows()] + + context = self.get_context_contact(batch) + + context.update({ + 'batch': batch, + 'normalized_batch': self.normalize_batch(batch), + '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_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)\ + .filter(model.CustomerOrderBatch.mode == self.enum.CUSTORDER_BATCH_MODE_CREATING)\ + .filter(model.CustomerOrderBatch.created_by == user)\ + .filter(model.CustomerOrderBatch.executed == None)\ + .one() + + except orm.exc.NoResultFound: + # no batch yet for this user, so make one + + batch = self.batch_handler.make_batch( + self.Session(), created_by=user, + mode=self.enum.CUSTORDER_BATCH_MODE_CREATING) + self.Session.add(batch) + self.Session.flush() + + return batch + + def start_over_entirely(self, batch): + self.batch_handler.do_delete(batch) + self.Session.flush() + + # send user back to normal "create" page; a new batch will be generated + # for them automatically + route_prefix = self.get_route_prefix() + url = self.request.route_url('{}.create'.format(route_prefix)) + return self.redirect(url) + + def delete_batch(self, 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 + url = self.get_index_url() + return self.redirect(url) + + def customer_autocomplete(self): + """ + Customer autocomplete logic, which invokes the handler. + """ + # TODO: deprecate / remove this + self.handler = self.batch_handler + term = self.request.GET['term'] + return self.batch_handler.customer_autocomplete(self.Session(), term, + user=self.request.user) + + def person_autocomplete(self): + """ + Person autocomplete logic, which invokes the handler. + """ + # TODO: deprecate / remove this + self.handler = self.batch_handler + term = self.request.GET['term'] + 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"} + + model = self.app.model + customer = self.Session.get(model.Customer, uuid) + if not customer: + return {'error': "Customer not found"} + + return self.info_for_customer(batch, data, customer) + + def info_for_customer(self, batch, data, customer): + + # most info comes from handler + info = self.batch_handler.get_customer_info(batch) + + # maybe add profile URL + if info['person_uuid']: + if self.request.has_perm('people.view_profile'): + info['contact_profile_url'] = self.request.route_url( + 'people.view_profile', uuid=info['person_uuid']), + + 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.batch_handler.new_order_requires_customer(): + + customer = self.Session.get(model.Customer, uuid) + if not customer: + return {'error': "Customer not found"} + kwargs['customer'] = customer + + else: + + 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.batch_handler.assign_contact(batch, **kwargs) + except ValueError as error: + return {'error': str(error)} + + self.Session.flush() + context = self.get_context_contact(batch) + context['success'] = True + return context + + def get_context_contact(self, batch): + context = { + 'customer_uuid': batch.customer_uuid, + 'person_uuid': batch.person_uuid, + 'phone_number': batch.phone_number, + 'contact_display': batch.contact_name, + 'email_address': batch.email_address, + '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, + 'new_customer_name': None, + 'new_customer_first_name': None, + 'new_customer_last_name': None, + 'new_customer_phone': None, + 'new_customer_email': None, + } + + pending = batch.pending_customer + if pending: + context.update({ + 'new_customer_first_name': pending.first_name, + 'new_customer_last_name': pending.last_name, + 'new_customer_name': pending.display_name, + 'new_customer_phone': pending.phone_number, + 'new_customer_email': pending.email_address, + }) + + # figure out if "contact is known" from user's perspective. + # if we have a uuid then it's definitely known, otherwise if + # 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.batch_handler.get_contact(batch) + if contact: + context['contact_is_known'] = True + else: + context['contact_is_known'] = not bool(pending) + + # maybe add profile URL + if batch.person_uuid: + if self.request.has_perm('people.view_profile'): + context['contact_profile_url'] = self.request.route_url( + 'people.view_profile', uuid=batch.person_uuid) + + return context + + def unassign_contact(self, batch, data): + self.batch_handler.unassign_contact(batch) + self.Session.flush() + context = self.get_context_contact(batch) + context['success'] = True + return context + + def update_phone_number(self, batch, data): + app = self.get_rattail_app() + + batch.phone_number = app.format_phone_number(data['phone_number']) + + if data.get('add_phone_number'): + batch.set_param('add_phone_number', True) + else: + batch.clear_param('add_phone_number') + + self.Session.flush() + return { + 'success': True, + 'phone_number': batch.phone_number, + 'add_phone_number': bool(batch.get_param('add_phone_number')), + } + + def update_email_address(self, batch, data): + + batch.email_address = data['email_address'] + + if data.get('add_email_address'): + batch.set_param('add_email_address', True) + else: + batch.clear_param('add_email_address') + + self.Session.flush() + return { + 'success': True, + 'email_address': batch.email_address, + 'add_email_address': bool(batch.get_param('add_email_address')), + } + + def update_pending_customer(self, batch, data): + + try: + self.batch_handler.update_pending_customer(batch, self.request.user, + data) + except Exception as error: + return {'error': str(error)} + + self.Session.flush() + context = self.get_context_contact(batch) + context['success'] = True + return context + + def product_autocomplete(self): + """ + Custom product autocomplete logic, which invokes the handler. + """ + term = self.request.GET['term'] + + # 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) + + # 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"} + + 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): + return self.batch_handler.uom_choices_for_product(product) + + def uom_choices_for_row(self, row): + return self.batch_handler.uom_choices_for_row(row) + + def info_for_product(self, batch, data, 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': 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): + 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': 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_weighed': row.product_weighed, + + 'case_quantity': self.app.render_quantity(row.case_quantity), + 'cases_ordered': self.app.render_quantity(row.cases_ordered), + 'units_ordered': self.app.render_quantity(row.units_ordered), + 'order_quantity': self.app.render_quantity(row.order_quantity), + 'order_uom': row.order_uom, + 'order_uom_choices': self.uom_choices_for_row(row), + 'discount_percent': self.app.render_quantity(row.discount_percent), + + 'department_display': row.department_name, + + 'unit_price': float(row.unit_price) if row.unit_price is not None else None, + 'unit_price_display': self.get_unit_price_display(row), + 'total_price': float(row.total_price) if row.total_price is not None else None, + 'total_price_display': 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 = self.app.render_quantity(row.order_quantity * row.case_quantity) + data.update({ + 'order_quantity_display': "{} {} (× {} {} = {} {})".format( + data['order_quantity'], + self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], + case_qty, + self.enum.UNIT_OF_MEASURE[unit_uom], + unit_qty, + self.enum.UNIT_OF_MEASURE[unit_uom]), + }) + else: + data.update({ + 'order_quantity_display': "{} {}".format( + 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.get(model.Product, uuid) + if not product: + return {'error': "Product not found"} + + kwargs = {} + if self.batch_handler.product_price_may_be_questionable(): + kwargs['price_needs_confirmation'] = data.get('price_needs_confirmation') + + 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)} + + def update_item(self, batch, data): + uuid = data.get('uuid') + if not uuid: + return {'error': "Must specify a row 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.get(model.Product, uuid) + if not product: + return {'error': "Product not found"} + + row.item_entry = product.uuid + row.product = product + 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 + + # 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)} + + def delete_item(self, batch, data): + + uuid = data.get('uuid') + if not uuid: + return {'error': "Must specify a row 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.batch_handler.do_remove_row(row) + return {'ok': True, + 'batch': self.normalize_batch(batch)} + + def submit_new_order(self, batch, data): + + reason = self.batch_handler.why_not_execute(batch, user=self.request.user) + if reason: + return {'error': reason} + + try: + result = self.execute_new_order_batch(batch, data) + except Exception as error: + 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): + return self.get_action_url('view', result) + + def execute_new_order_batch(self, batch, data): + return self.batch_handler.do_execute(batch, self.request.user) + + def fetch_order_data(self): + app = self.get_rattail_app() + model = self.model + + order = None + uuid = self.request.GET.get('uuid') + if uuid: + order = self.Session.get(model.CustomerOrder, uuid) + if not order: + # raise self.notfound() + return {'error': "Customer order not found"} + + address = None + if self.batch_handler.new_order_requires_customer(): + contact = order.customer + else: + contact = order.person + if contact and contact.address: + a = contact.address + address = { + 'street_1': a.street, + 'street_2': a.street2, + 'city': a.city, + 'state': a.state, + 'zip': a.zipcode, + } + + # gather all the order items + items = [] + grand_total = 0 + for item in order.items: + item_data = { + 'uuid': item.uuid, + 'special_order': False, # TODO + 'product_description': item.product_description, + 'order_quantity': app.render_quantity(item.order_quantity), + 'department': item.department_name, + 'price': app.render_currency(item.unit_price), + 'total': app.render_currency(item.total_price), + } + items.append(item_data) + grand_total += item.total_price + + return { + 'uuid': order.uuid, + 'id': order.id, + 'created_display': app.render_datetime(app.localtime(order.created, from_utc=True)), + 'contact_display': str(contact or ''), + 'address': address, + 'phone_display': str(contact.phone) if contact and contact.phone else "", + 'email_display': str(contact.email) if contact and contact.email else "", + 'items': items, + 'grand_total_display': app.render_currency(grand_total), + } + + def configure_get_simple_settings(self): + 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): + cls._order_defaults(config) + cls._defaults(config) + + @classmethod + 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), + '{}/person-autocomplete'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='person_autocomplete', + route_name='{}.person_autocomplete'.format(route_prefix), + renderer='json', + permission='people.list') + + # customer autocomplete + config.add_route('{}.customer_autocomplete'.format(route_prefix), + '{}/customer-autocomplete'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='customer_autocomplete', + route_name='{}.customer_autocomplete'.format(route_prefix), + renderer='json', + permission='customers.list') + + # custom product autocomplete + config.add_route('{}.product_autocomplete'.format(route_prefix), + '{}/product-autocomplete'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='product_autocomplete', + route_name='{}.product_autocomplete'.format(route_prefix), + 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 defaults(config, **kwargs): + base = globals() + + CustomerOrderView = kwargs.get('CustomerOrderView', base['CustomerOrderView']) + CustomerOrderView.defaults(config) def includeme(config): - CustomerOrdersView.defaults(config) + defaults(config) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 1d117fbd..2b955b5f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.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,33 +24,389 @@ 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 tailbone.views import MasterView3 as MasterView +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 DataSyncChangesView(MasterView): +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 = { + 'batch_id': "Batch ID", + } + grid_columns = [ 'source', + 'batch_id', + 'batch_sequence', 'payload_type', 'payload_key', 'deletion', @@ -58,44 +414,54 @@ class DataSyncChangesView(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(DataSyncChangesView, self).configure_grid(g) - g.default_sortkey = 'obtained' + super().configure_grid(g) + + # batch_sequence + g.set_label('batch_sequence', "Batch Seq.") + g.filters['batch_sequence'].label = "Batch Sequence" + + g.set_sort_defaults('obtained') g.set_type('obtained', 'datetime') - 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 template_kwargs_index(self, **kwargs): + kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart')) + return kwargs - def mobile_index(self): - return {} + def configure_form(self, f): + super().configure_form(f) - @classmethod - def defaults(cls, config): + f.set_readonly('obtained') - # fix permission group title - config.add_tailbone_permission_group('datasync', label="DataSync") - # restart daemon - config.add_route('datasync.restart', '/datasync/restart') - config.add_view(cls, attr='restart', route_name='datasync.restart', permission='datasync.restart') - config.add_tailbone_permission('datasync', 'datasync.restart', label="Restart DataSync Daemon") +# TODO: deprecate / remove this +DataSyncChangesView = DataSyncChangeView - # mobile - config.add_route('datasync.mobile', '/mobile/datasync/') - config.add_view(cls, attr='mobile_index', route_name='datasync.mobile', - permission='datasync.restart', renderer='/mobile/datasync.mako') - cls._defaults(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): - DataSyncChangesView.defaults(config) + defaults(config) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 85ac36a1..47de8dca 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.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,65 +24,194 @@ Department Views """ -from __future__ import unicode_literals, absolute_import +from rattail.db.model import Department, Product -import six +from webhelpers2.html import HTML -from rattail.db import model - -from tailbone import grids -from tailbone.views import MasterView3 as MasterView, AutocompleteView +from tailbone.views import MasterView -class DepartmentsView(MasterView): +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 = [ 'number', 'name', 'product', 'personnel', + 'tax', + 'food_stampable', + 'exempt_from_gross_sales', + ] + + form_fields = [ + 'number', + 'name', + 'product', + 'personnel', + 'tax', + 'food_stampable', + 'exempt_from_gross_sales', + 'default_custorder_discount', + 'allow_product_deletions', + 'employees', + ] + + has_rows = True + model_row_class = Product + rows_title = "Products" + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'upc', + 'brand', + 'description', + 'size', + 'vendor', + 'regular_price', + 'current_price', ] def configure_grid(self, g): - super(DepartmentsView, 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.default_sortkey = '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(DepartmentsView, self).configure_form(f) + super().configure_form(f) + f.remove_field('subdepartments') - f.remove_field('employees') + + if self.creating or self.editing: + f.remove('employees') + else: + f.set_renderer('employees', self.render_employees) + f.set_type('product', 'boolean') f.set_type('personnel', 'boolean') - def template_kwargs_view(self, **kwargs): - department = kwargs['instance'] - if department.employees: - employees = sorted(department.employees, key=six.text_type) - actions = [ - grids.GridAction('view', icon='zoomin', - url=lambda r, i: self.request.route_url('employees.view', uuid=r.uuid)) - ] - kwargs['employees'] = grids.Grid(None, employees, ['display_name'], request=self.request, - model_class=model.Employee, main_actions=actions) + # tax + if self.creating: + # TODO: make this editable instead + f.remove('tax') else: - kwargs['employees'] = None + 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( + self.request, + key=f'{route_prefix}.employees', + data=[], + columns=[ + 'first_name', + 'last_name', + ], + sortable=True, + sorters={'first_name': True, 'last_name': True}, + ) + + if self.request.has_perm('employees.view'): + g.actions.append(self.make_action('view', icon='eye')) + if self.request.has_perm('employees.edit'): + g.actions.append(self.make_action('edit', icon='edit')) + + return HTML.literal( + g.render_table_element(data_prop='employeesData')) + + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + department = kwargs['instance'] + department_employees = sorted(department.employees, key=str) + + 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 + def before_delete(self, department): + """ + 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() + if count: + self.request.session.flash("Will not delete department which still has {} products: {}".format( + count, department), 'error') + 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) + + def get_parent(self, product): + return product.department + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + app = self.get_rattail_app() + self.handler = app.get_products_handler() + g.set_renderer('regular_price', self.render_price) + g.set_renderer('current_price', self.render_price) + + g.set_sort_defaults('upc') + + def render_price(self, product, field): + if not product.not_for_sale: + price = product[field] + if price: + return self.handler.render_price(price) + + def row_view_action_url(self, product, i): + return self.request.route_url('products.view', uuid=product.uuid) + def list_by_vendor(self): """ View list of departments by vendor """ + model = self.model data = self.Session.query(model.Department)\ .outerjoin(model.Product)\ .join(model.ProductCost)\ @@ -91,20 +220,14 @@ class DepartmentsView(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 = b'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): @@ -120,17 +243,12 @@ class DepartmentsView(MasterView): cls._defaults(config) -class DepartmentsAutocomplete(AutocompleteView): +def defaults(config, **kwargs): + base = globals() - mapped_class = model.Department - fieldname = 'name' + DepartmentView = kwargs.get('DepartmentView', base['DepartmentView']) + DepartmentView.defaults(config) def includeme(config): - - # autocomplete - config.add_route('departments.autocomplete', '/departments/autocomplete') - config.add_view(DepartmentsAutocomplete, route_name='departments.autocomplete', - renderer='json', permission='departments.list') - - DepartmentsView.defaults(config) + defaults(config) diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py index 6fa7acd5..1c9abde1 100644 --- a/tailbone/views/depositlinks.py +++ b/tailbone/views/depositlinks.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -28,11 +28,10 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone import forms -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView -class DepositLinksView(MasterView): +class DepositLinkView(MasterView): """ Master view for deposit links. """ @@ -46,23 +45,31 @@ class DepositLinksView(MasterView): 'amount', ] + form_fields = [ + 'code', + 'description', + 'amount', + ] + def configure_grid(self, g): - super(DepositLinksView, self).configure_grid(g) + super(DepositLinkView, self).configure_grid(g) g.filters['description'].default_active = True g.filters['description'].default_verb = 'contains' - g.default_sortkey = 'code' + g.set_sort_defaults('code') g.set_type('amount', 'currency') g.set_link('code') g.set_link('description') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.code, - fs.description, - fs.amount, - ]) +# TODO: deprecate / remove this +DepositLinksView = DepositLinkView + + +def defaults(config, **kwargs): + base = globals() + + DepositLinkView = kwargs.get('DepositLinkView', base['DepositLinkView']) + DepositLinkView.defaults(config) def includeme(config): - DepositLinksView.defaults(config) + defaults(config) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 3aa1b014..98bd4295 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.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,76 +24,134 @@ Email Views """ -from __future__ import unicode_literals, absolute_import +import logging +import re +import warnings -from rattail import mail -from rattail.db import api -from rattail.config import parse_list +from wuttjamaican.util import parse_list -import formalchemy -from formalchemy.helpers import text_area -from pyramid.httpexceptions import HTTPFound -from webhelpers2.html import HTML +from rattail.db.model import EmailAttempt +from rattail.util import simple_error -from tailbone import forms +import colander +from deform import widget as dfwidget + +from tailbone import grids from tailbone.db import Session -from tailbone.views import View, MasterView2 as MasterView +from tailbone.views import View, MasterView -class EmailListFieldRenderer(formalchemy.TextAreaFieldRenderer): - - def render(self, **kwargs): - if isinstance(kwargs.get('size'), tuple): - kwargs['size'] = 'x'.join([str(i) for i in kwargs['size']]) - value = '\n'.join(parse_list(self.value)) - return text_area(self.name, content=value, **kwargs) +log = logging.getLogger(__name__) -class ProfilesView(MasterView): +class EmailSettingView(MasterView): """ Master view for email admin (settings/preview). """ normalized_model_name = 'emailprofile' - model_title = "Email Profile" + model_title = "Email Setting" model_key = 'key' - url_prefix = '/email/profiles' + url_prefix = '/settings/email' filterable = False pageable = False creatable = False deletable = False + configurable = True + config_title = "Email" + grid_columns = [ 'key', 'prefix', 'subject', 'to', 'enabled', + 'hidden', ] + form_fields = [ + 'key', + 'fallback_key', + 'description', + 'prefix', + 'subject', + 'sender', + 'replyto', + 'to', + 'cc', + 'bcc', + 'enabled', + 'hidden', + ] + + def __init__(self, request): + 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_email_handler() + def get_data(self, session=None): data = [] - for email in mail.iter_emails(self.rattail_config): - 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['to'] = g.make_simple_sorter('to', foldcase=True) - g.sorters['enabled'] = g.make_simple_sorter('enabled') - g.default_sortkey = 'key' + 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_renderer('to', self.render_to) g.set_link('key') g.set_link('subject') - # Make edit link visible by default, no "More" actions. - if g.more_actions: - g.main_actions.append(g.more_actions.pop()) + g.set_searchable('key') + g.set_searchable('subject') + + # to + g.set_renderer('to', self.render_to_short) + + # 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'] + if self.rattail_config.production(): + if profile.dynamic_to: + if profile.dynamic_to_help: + return profile.dynamic_to_help - def render_to(self, email, column): value = email['to'] if not value: return "" @@ -107,25 +165,28 @@ class ProfilesView(MasterView): recips = email.get_recips(type_) if recips: return ', '.join(recips) - data = email.sample_data(self.request) - return { + data = email.obtain_sample_data(self.request) + normal = { '_email': email, 'key': email.key, 'fallback_key': email.fallback_key, 'description': email.__doc__, - 'prefix': email.get_prefix(data, magic=False), - 'subject': email.get_subject(data, render=False), - 'sender': email.get_sender(), - 'replyto': email.get_replyto(), - 'to': get_recips('to'), - 'cc': get_recips('cc'), - 'bcc': get_recips('bcc'), + 'prefix': email.get_prefix(data, magic=False) or '', + 'subject': email.get_subject(data, render=False) or '', + 'sender': email.get_sender() or '', + 'replyto': email.get_replyto() or '', + 'to': get_recips('to') or '', + 'cc': get_recips('cc') or '', + '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(mail.get_email(self.rattail_config, key)) + return self.normalize(self.email_handler.get_email(key)) def get_instance_title(self, email): return email['_email'].get_complete_subject(render=False) @@ -140,64 +201,265 @@ class ProfilesView(MasterView): return profile['key'] != 'user_feedback' return True - def make_form(self, email, **kwargs): - """ - Make a simple form for use with CRUD views. - """ - fs = forms.GenericFieldSet(email) - fs.append(formalchemy.Field('key', value=email['key'], readonly=True)) - fs.append(formalchemy.Field('fallback_key', value=email['fallback_key'], readonly=True)) - fs.append(formalchemy.Field('description', value=email['description'], readonly=True)) - fs.append(formalchemy.Field('prefix', value=email['prefix'], label="Subject Prefix")) - fs.append(formalchemy.Field('subject', value=email['subject'], label="Subject Text")) - fs.append(formalchemy.Field('sender', value=email['sender'], label="From")) - fs.append(formalchemy.Field('replyto', value=email['replyto'], label="Reply-To")) - fs.append(formalchemy.Field('to', value=email['to'], renderer=EmailListFieldRenderer, size='60x6')) - fs.append(formalchemy.Field('cc', value=email['cc'], renderer=EmailListFieldRenderer, size='60x2')) - fs.append(formalchemy.Field('bcc', value=email['bcc'], renderer=EmailListFieldRenderer, size='60x2')) - fs.append(formalchemy.Field('enabled', type=formalchemy.types.Boolean, value=email['enabled'])) + def configure_form(self, f): + super().configure_form(f) + profile = f.model_instance['_email'] - form = forms.AlchemyForm(self.request, fs, - creating=self.creating, - editing=self.editing, - action_url=self.request.current_route_url(_query=None), - cancel_url=self.get_action_url('view', email)) - form.readonly = self.viewing - return form + # key + f.set_readonly('key') - def save_form(self, form): - fs = form.fieldset - fs.sync() - key = fs.key._value + # fallback_key + f.set_readonly('fallback_key') - session = Session() - api.save_setting(session, 'rattail.mail.{}.prefix'.format(key), fs.prefix._value) - api.save_setting(session, 'rattail.mail.{}.subject'.format(key), fs.subject._value) - api.save_setting(session, 'rattail.mail.{}.from'.format(key), fs.sender._value) - api.save_setting(session, 'rattail.mail.{}.replyto'.format(key), fs.replyto._value) - api.save_setting(session, 'rattail.mail.{}.to'.format(key), (fs.to._value or '').replace('\n', ', ')) - api.save_setting(session, 'rattail.mail.{}.cc'.format(key), (fs.cc._value or '').replace('\n', ', ')) - api.save_setting(session, 'rattail.mail.{}.bcc'.format(key), (fs.bcc._value or '').replace('\n', ', ')) - api.save_setting(session, 'rattail.mail.{}.enabled'.format(key), unicode(fs.enabled._value).lower()) + # description + f.set_readonly('description') + + # prefix + f.set_label('prefix', "Subject Prefix") + + # subject + f.set_label('subject', "Subject Text") + + # sender + f.set_label('sender', "From") + + # replyto + f.set_label('replyto', "Reply-To") + + # to + f.set_widget('to', dfwidget.TextAreaWidget(cols=60, rows=6)) + if self.rattail_config.production(): + if profile.dynamic_to: + f.set_readonly('to') + if profile.dynamic_to_help: + f.model_instance['to'] = profile.dynamic_to_help + + # cc + f.set_widget('cc', dfwidget.TextAreaWidget(cols=60, rows=2)) + + # bcc + f.set_widget('bcc', dfwidget.TextAreaWidget(cols=60, rows=2)) + + # 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): + 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() + 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'] = mail.get_email(self.rattail_config, 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 + recipients as a "list" within the text area, i.e. one recipient per line. + Then the list is collapsed to a comma-delimited string for storage. + """ + + def serialize(self, node, appstruct): + if appstruct is colander.null: + return colander.null + recips = parse_list(appstruct) + return '\n'.join(recips) + + def deserialize(self, node, cstruct): + if cstruct == '' and self.allow_empty: + return '' + if not cstruct: + return colander.null + recips = parse_list(cstruct) + return ', '.join(recips) + + +class EmailProfileSchema(colander.MappingSchema): + + prefix = colander.SchemaNode(colander.String()) + + subject = colander.SchemaNode(colander.String()) + + sender = colander.SchemaNode(colander.String()) + + replyto = colander.SchemaNode(colander.String(), missing='') + + to = colander.SchemaNode(RecipientsType()) + + cc = colander.SchemaNode(RecipientsType(), missing='') + + bcc = colander.SchemaNode(RecipientsType(), missing='') + + enabled = colander.SchemaNode(colander.Boolean()) + + hidden = colander.SchemaNode(colander.Boolean()) + class EmailPreview(View): """ Lists available email templates, and can show previews of each. """ + def __init__(self, request): + super().__init__(request) + + 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): # Forms submitted via POST are only used for sending emails. if self.request.method == 'POST': self.email_template() - return HTTPFound(location=self.request.get_referrer( - default=self.request.route_url('emailprofiles'))) + url = self.request.get_referrer(default=self.request.route_url('emailprofiles')) + return self.redirect(url) # Maybe render a preview? key = self.request.GET.get('key') @@ -210,34 +472,35 @@ class EmailPreview(View): def email_template(self): recipient = self.request.POST.get('recipient') if recipient: - keys = filter(lambda k: k.startswith('send_'), self.request.POST.iterkeys()) - key = keys[0][5:] if keys else None + key = self.request.POST.get('email_key') if key: - email = mail.get_email(self.rattail_config, key) - data = email.sample_data(self.request) - msg = email.make_message(data) + email = self.email_handler.get_email(key) - subject = msg['Subject'] - del msg['Subject'] - msg['Subject'] = "[preview] {0}".format(subject) + context = self.email_handler.make_context() + context.update(email.obtain_sample_data(self.request)) - del msg['To'] - del msg['Cc'] - del msg['Bcc'] - msg['To'] = recipient - - sent = mail.deliver_message(self.rattail_config, key, msg) - - self.request.session.flash("Preview for '{}' was {}emailed to {}".format( - key, '' if sent else '(NOT) ', 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 = mail.get_email(self.rattail_config, key) + email = self.email_handler.get_email(key) template = email.get_template(type_) - data = email.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 = b'text/plain' + self.request.response.content_type = str('text/plain') return self.request.response @classmethod @@ -251,7 +514,99 @@ class EmailPreview(View): "Send preview email") +class EmailAttemptView(MasterView): + """ + Master view for email attempts. + """ + model_class = EmailAttempt + route_prefix = 'email_attempts' + url_prefix = '/email/attempts' + creatable = False + editable = False + deletable = False + + labels = { + 'status_code': "Status", + } + + grid_columns = [ + 'key', + 'sender', + 'subject', + 'to', + 'sent', + 'status_code', + ] + + form_fields = [ + 'key', + 'sender', + 'subject', + 'to', + 'cc', + 'bcc', + 'sent', + 'status_code', + 'status_text', + ] + + def configure_grid(self, g): + super().configure_grid(g) + + # sent + g.set_sort_defaults('sent', 'desc') + + # status_code + g.set_enum('status_code', self.enum.EMAIL_ATTEMPT) + + # to + g.set_renderer('to', self.render_to_short) + + # links + g.set_link('key') + g.set_link('sender') + g.set_link('subject') + g.set_link('to') + + to_pattern = re.compile(r'^\{(.*)\}$') + + def render_to_short(self, attempt, column): + value = attempt.to + if not value: + return + + match = self.to_pattern.match(value) + if match: + recips = parse_list(match.group(1)) + if len(recips) > 2: + recips = recips[:2] + recips.append('...') + return ', '.join(recips) + + return value + + def configure_form(self, 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 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): - ProfilesView.defaults(config) - EmailPreview.defaults(config) + defaults(config) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index f02154c4..debd8fcb 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,25 +24,34 @@ Employee Views """ -from __future__ import unicode_literals, absolute_import - import sqlalchemy as sa from rattail.db import model -import formalchemy as fa +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags, HTML -from tailbone import forms, grids -from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView, AutocompleteView +from tailbone import grids +from tailbone.views import MasterView -class EmployeesView(MasterView): +class EmployeeView(MasterView): """ Master view for the Employee class. """ model_class = model.Employee has_versions = True + touchable = True + supports_autocomplete = True + results_downloadable = True + configurable = True + + labels = { + 'id': "ID", + 'display_name': "Short Name", + 'phone': "Phone Number", + } grid_columns = [ 'id', @@ -51,111 +60,294 @@ class EmployeesView(MasterView): 'phone', 'email', 'status', + 'username', ] - def configure_grid(self, g): - super(EmployeesView, self).configure_grid(g) + form_fields = [ + 'person', + 'first_name', + 'last_name', + 'display_name', + 'phone', + 'email', + 'status', + 'full_time', + 'full_time_start', + 'id', + 'users', + 'stores', + 'departments', + ] - g.joiners['phone'] = lambda q: q.outerjoin(model.EmployeePhoneNumber, sa.and_( + 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() + + # phone + g.set_joiner('phone', lambda q: q.outerjoin(model.EmployeePhoneNumber, sa.and_( model.EmployeePhoneNumber.parent_uuid == model.Employee.uuid, - model.EmployeePhoneNumber.preference == 1)) + model.EmployeePhoneNumber.preference == 1))) + g.set_filter('phone', model.EmployeePhoneNumber.number, + label="Phone Number", + factory=grids.filters.AlchemyPhoneNumberFilter) + g.set_sorter('phone', model.EmployeePhoneNumber.number) + + # email g.joiners['email'] = lambda q: q.outerjoin(model.EmployeeEmailAddress, sa.and_( model.EmployeeEmailAddress.parent_uuid == model.Employee.uuid, model.EmployeeEmailAddress.preference == 1)) - - 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) - g.filters['email'] = g.make_filter('email', model.EmployeeEmailAddress.address, label="Email Address") - g.filters['phone'] = g.make_filter('phone', model.EmployeePhoneNumber.number, - label="Phone Number") - if self.request.has_perm('employees.edit'): + # 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'): + g.set_joiner('username', lambda q: q.outerjoin(model.User)) + g.set_filter('username', model.User.username) + g.set_sorter('username', model.User.username) + g.set_renderer('username', self.grid_render_username) + else: + g.remove('username') + + # id + if self.has_perm('edit'): + g.set_link('id') + else: + g.remove('id') + del g.filters['id'] + + # status + 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' - g.filters['status'].default_value = self.enum.EMPLOYEE_STATUS_CURRENT - g.filters['status'].set_value_renderer(grids.filters.EnumValueRenderer(self.enum.EMPLOYEE_STATUS)) + g.filters['status'].default_value = str(self.enum.EMPLOYEE_STATUS_CURRENT) else: - del g.filters['id'] + g.remove('status') del g.filters['status'] - g.filters['first_name'].default_active = True - g.filters['first_name'].default_verb = 'contains' + g.set_sorter('email', model.EmployeeEmailAddress.address) - 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.sorters['phone'] = lambda q, d: q.order_by(getattr(model.EmployeePhoneNumber.number, d)()) - - g.default_sortkey = 'first_name' - - g.set_enum('status', self.enum.EMPLOYEE_STATUS) - - g.set_label('id', "ID") - g.set_label('phone', "Phone Number") g.set_label('email', "Email Address") - g.set_link('id') - 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()): - if not self.request.has_perm('employees.edit'): - g.hide_column('id') - g.hide_column('status') + # 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 + if not person: + return "" + return ", ".join([u.username for u in person.users]) + + def grid_extra_class(self, employee, i): + if employee.status == self.enum.EMPLOYEE_STATUS_FORMER: + return 'warning' + + def is_employee_protected(self, employee): + for user in employee.person.users: + if self.user_is_protected(user): + return True + return False def editable_instance(self, employee): - if self.rattail_config.demo(): - return not bool(employee.user and employee.user.username == 'chuck') - return True + if self.request.is_root: + return True + return not self.is_employee_protected(employee) def deletable_instance(self, employee): - if self.rattail_config.demo(): - return not bool(employee.user and employee.user.username == 'chuck') - return True + if self.request.is_root: + return True + return not self.is_employee_protected(employee) - def _preconfigure_fieldset(self, fs): - fs.append(forms.AssociationProxyField('first_name')) - fs.append(forms.AssociationProxyField('last_name')) - fs.append(StoresField('stores')) - fs.append(DepartmentsField('departments')) + def configure_form(self, f): + super().configure_form(f) + employee = f.model_instance - fs.person.set(renderer=forms.renderers.PersonFieldRenderer) - fs.display_name.set(label="Short Name") - fs.phone.set(label="Phone Number", readonly=True) - fs.email.set(label="Email Address", readonly=True) - fs.status.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.EMPLOYEE_STATUS)) - fs.id.set(label="ID") + f.set_renderer('person', self.render_person) + + if self.creating or self.editing: + f.remove('users') + else: + f.set_readonly('users') + f.set_renderer('users', self.render_users) + + f.set_renderer('stores', self.render_stores) + 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, 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), + values=store_values)) + if self.editing: + f.set_default('stores', [s.uuid for s in employee.stores]) + + f.set_renderer('departments', self.render_departments) + 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, 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), + values=dept_values)) + if self.editing: + f.set_default('departments', [d.uuid for d in employee.departments]) + + f.set_enum('status', self.enum.EMPLOYEE_STATUS) + + f.set_type('full_time_start', 'date_jquery') + if self.editing: + # TODO: this should not be needed (association proxy) + f.set_default('full_time_start', employee.full_time_start) + + f.set_readonly('person') + f.set_readonly('phone') + f.set_readonly('email') + + f.set_label('email', "Email Address") - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.person, - fs.first_name, - fs.last_name, - fs.display_name, - fs.phone, - fs.email, - fs.status, - fs.full_time, - fs.full_time_start, - fs.id, - fs.stores, - fs.departments, - ]) if not self.viewing: - del fs.first_name - del fs.last_name + f.remove_fields('first_name', 'last_name') + + def objectify(self, form, data=None): + if data is None: + data = form.validated + employee = super().objectify(form, data) + self.update_stores(employee, data) + self.update_departments(employee, data) + return employee + + def update_stores(self, employee, data): + if 'stores' not in data: + return + old_stores = set([s.uuid for s in employee.stores]) + new_stores = data['stores'] + for uuid in new_stores: + if uuid not in old_stores: + employee._stores.append(model.EmployeeStore(store_uuid=uuid)) + for uuid in old_stores: + if uuid not in new_stores: + store = self.Session.get(model.Store, uuid) + employee.stores.remove(store) + + def update_departments(self, employee, data): + if 'departments' not in data: + return + old_depts = set([d.uuid for d in employee.departments]) + new_depts = data['departments'] + for uuid in new_depts: + if uuid not in old_depts: + employee._departments.append(model.EmployeeDepartment(department_uuid=uuid)) + for uuid in old_depts: + if uuid not in new_depts: + dept = self.Session.get(model.Department, uuid) + employee.departments.remove(dept) + + def get_possible_stores(self): + return self.Session.query(model.Store)\ + .order_by(model.Store.name) + + def get_possible_departments(self): + return self.Session.query(model.Department)\ + .order_by(model.Department.name) + + def render_person(self, employee, field): + person = employee.person if employee else None + if not person: + return "" + text = str(person) + url = self.request.route_url('people.view', uuid=person.uuid) + return tags.link_to(text, url) + + def render_stores(self, employee, field): + stores = employee.stores if employee else None + if not stores: + return "" + items = [] + 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): + departments = employee.departments if employee else None + if not departments: + return "" + items = [] + 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 [ @@ -166,84 +358,43 @@ class EmployeesView(MasterView): (model.EmployeeDepartment, 'employee_uuid'), ] + def configure_get_simple_settings(self): + return [ -class StoresField(fa.Field): + # General + {'section': 'rattail', + 'option': 'employees.straight_to_profile', + 'type': bool}, + ] - def __init__(self, name, **kwargs): - kwargs.setdefault('type', fa.types.Set) - kwargs.setdefault('options', Session.query(model.Store).order_by(model.Store.name)) - kwargs.setdefault('value', self.get_value) - kwargs.setdefault('multiple', True) - kwargs.setdefault('size', 3) - fa.Field.__init__(self, name=name, **kwargs) + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._employee_defaults(config) - def get_value(self, employee): - return [s.uuid for s in employee.stores] + @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() - def sync(self): - if not self.is_readonly(): - employee = self.parent.model - old_stores = set([s.uuid for s in employee.stores]) - new_stores = set(self._deserialize()) - for uuid in new_stores: - if uuid not in old_stores: - employee._stores.append(model.EmployeeStore(store_uuid=uuid)) - for uuid in old_stores: - if uuid not in new_stores: - store = Session.query(model.Store).get(uuid) - assert store - employee.stores.remove(store) + # 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)) -class DepartmentsField(fa.Field): +def defaults(config, **kwargs): + base = globals() - def __init__(self, name, **kwargs): - kwargs.setdefault('type', fa.types.Set) - kwargs.setdefault('options', Session.query(model.Department).order_by(model.Department.name)) - kwargs.setdefault('value', self.get_value) - kwargs.setdefault('multiple', True) - kwargs.setdefault('size', 10) - fa.Field.__init__(self, name=name, **kwargs) - - def get_value(self, employee): - return [d.uuid for d in employee.departments] - - def sync(self): - if not self.is_readonly(): - employee = self.parent.model - old_depts = set([d.uuid for d in employee.departments]) - new_depts = set(self._deserialize()) - for uuid in new_depts: - if uuid not in old_depts: - employee._departments.append(model.EmployeeDepartment(department_uuid=uuid)) - for uuid in old_depts: - if uuid not in new_depts: - dept = Session.query(model.Department).get(uuid) - assert dept - employee.departments.remove(dept) - - -class EmployeesAutocomplete(AutocompleteView): - """ - Autocomplete view for the Employee model, but restricted to return only - results for current employees. - """ - mapped_class = model.Person - fieldname = 'display_name' - - def filter_query(self, q): - return q.join(model.Employee)\ - .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) - - def value(self, person): - return person.employee.uuid + EmployeeView = kwargs.get('EmployeeView', base['EmployeeView']) + EmployeeView.defaults(config) def includeme(config): - - # autocomplete - config.add_route('employees.autocomplete', '/employees/autocomplete') - config.add_view(EmployeesAutocomplete, route_name='employees.autocomplete', - renderer='json', permission='employees.list') - - EmployeesView.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 4d121a0b..44df359f 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.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,17 +24,13 @@ Master class for generic export history views """ -from __future__ import unicode_literals, absolute_import - import os +import shutil -from rattail.db import model - -import formalchemy as fa from pyramid.response import FileResponse +from webhelpers2.html import tags -from tailbone import forms -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView class ExportMasterView(MasterView): @@ -43,7 +39,13 @@ class ExportMasterView(MasterView): """ creatable = False editable = False - export_has_file = False + downloadable = False + delete_export_files = False + + labels = { + 'id': "ID", + 'created_by': "Created by", + } grid_columns = [ 'id', @@ -52,56 +54,111 @@ class ExportMasterView(MasterView): 'record_count', ] + form_fields = [ + 'id', + 'created', + 'created_by', + 'record_count', + ] + def get_export_key(self): if hasattr(self, 'export_key'): return self.export_key - return + + cls = self.get_model_class() + return cls.export_key def get_file_path(self, export, makedirs=False): - return self.rattail_config.export_filepath(self.export_key, + return self.rattail_config.export_filepath(self.get_export_key(), export.uuid, export.filename, makedirs=makedirs) + def download_path(self, export, filename): + # TODO: this assumes 'filename' default! + return self.get_file_path(export) + def configure_grid(self, g): - super(ExportMasterView, self).configure_grid(g) - - 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) - g.default_sortkey = 'created' - g.default_sortdir = 'desc' + super().configure_grid(g) + model = self.model + # id g.set_renderer('id', self.render_id) - - g.set_label('id', "ID") - g.set_label('created_by', "Created by") - g.set_link('id') - def render_id(self, export, column): + # filename + g.set_link('filename') + + # created + g.set_sort_defaults('created', 'desc') + + # 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 _preconfigure_fieldset(self, fs): - fs.id.set(label="ID", renderer=forms.renderers.BatchIDFieldRenderer) - fs.created_by.set(label="Created by", renderer=forms.renderers.UserFieldRenderer, - attrs={'hyperlink': True}) - if self.export_has_file and self.viewing: - download = forms.renderers.FileFieldRenderer.new( - self, storage_path=self.rattail_config.export_filedir(self.export_key), - file_path=self.get_file_path(fs.model), download_url=self.get_download_url) - fs.append(fa.Field('download', renderer=download)) + def configure_form(self, f): + super().configure_form(f) + export = f.model_instance - def configure_fieldset(self, fs): - fields = [ - fs.id, - fs.created, - fs.created_by, - fs.record_count, - ] - if self.export_has_file and self.viewing: - fields.append(fs.download) - fs.configure(include=fields) + # NOTE: we try to handle the 'creating' scenario even though this class + # doesn't officially support that; just in case a subclass does want to + + # id + if self.creating: + f.remove_field('id') + else: + f.set_readonly('id') + f.set_renderer('id', self.render_id) + f.set_label('id', "ID") + + # created + if self.creating: + f.remove_field('created') + else: + f.set_readonly('created') + f.set_type('created', 'datetime') + + # created_by + if self.creating: + f.remove_field('created_by') + else: + f.set_readonly('created_by') + f.set_renderer('created_by', self.render_created_by) + f.set_label('created_by', "Created by") + + # record_count + if self.creating: + f.remove_field('record_count') + else: + f.set_readonly('record_count') + + # filename + if self.editing: + f.remove_field('filename') + else: + f.set_readonly('filename') + f.set_renderer('filename', self.render_downloadable_file) + + def objectify(self, form, data=None): + obj = super().objectify(form, data=data) + if self.creating: + obj.created_by = self.request.user + return obj + + def render_created_by(self, export, field): + user = export.created_by + if not user: + return "" + 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) + return text def get_download_url(self, filename): uuid = self.request.matchdict['uuid'] @@ -114,41 +171,20 @@ class ExportMasterView(MasterView): export = self.get_instance() path = self.get_file_path(export) response = FileResponse(path, request=self.request) - response.headers[b'Content-Length'] = str(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): """ - Delete the export file also, if it exists. + Delete the export's files as well as the export itself. """ - if self.export_has_file: + # delete files for the export, if applicable + if self.delete_export_files: path = self.get_file_path(export) - if os.path.exists(path): - os.remove(path) - os.rmdir(os.path.dirname(path)) - super(ExportMasterView, self).delete_instance(export) + dirname = os.path.dirname(path) + if os.path.exists(dirname): + shutil.rmtree(dirname) - @classmethod - def defaults(cls, config): - """ - Provide default configuration for a master view. - """ - cls._defaults(config) - cls._export_defaults(config) - - @classmethod - def _export_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() - - # download export file - if cls.export_has_file: - config.add_route('{}.download'.format(route_prefix), '{}/{{{}}}/download'.format(url_prefix, model_key)) - config.add_view(cls, attr='download', route_name='{}.download'.format(route_prefix), - permission='{}.download'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.download'.format(permission_prefix), - "Download {} data file".format(model_title)) + # continue w/ normal deletion + super().delete_instance(export) diff --git a/tailbone/views/families.py b/tailbone/views/families.py index 97ce44bc..2d445b78 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -28,10 +28,10 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView -class FamiliesView(MasterView): +class FamilyView(MasterView): """ Master view for the Family class. """ @@ -39,6 +39,7 @@ class FamiliesView(MasterView): model_title_plural = "Families" route_prefix = 'families' has_versions = True + results_downloadable = True grid_key = 'families' grid_columns = [ @@ -46,19 +47,73 @@ class FamiliesView(MasterView): 'name', ] + form_fields = [ + 'code', + 'name', + ] + + has_rows = True + model_row_class = model.Product + + row_grid_columns = [ + '_product_key_', + 'brand', + 'description', + 'size', + 'department', + 'vendor', + 'regular_price', + 'current_price', + ] + def configure_grid(self, g): + super(FamilyView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'code' - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.code, - fs.name, - ]) - return fs + g.set_sort_defaults('code') + + g.set_link('code') + g.set_link('name') + + def get_row_data(self, family): + return self.Session.query(model.Product)\ + .filter(model.Product.family == family) + + def get_parent(self, product): + return product.family + + def configure_row_grid(self, g): + super(FamilyView, self).configure_row_grid(g) + + app = self.get_rattail_app() + self.handler = app.get_products_handler() + g.set_renderer('regular_price', self.render_price) + g.set_renderer('current_price', self.render_price) + + 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: + price = product[field] + if price: + return self.handler.render_price(price) + + def row_view_action_url(self, product, i): + return self.request.route_url('products.view', uuid=product.uuid) + +# TODO: deprecate / remove this +FamiliesView = FamilyView + + +def defaults(config, **kwargs): + base = globals() + + FamilyView = kwargs.get('FamilyView', base['FamilyView']) + FamilyView.defaults(config) def includeme(config): - FamiliesView.defaults(config) + defaults(config) diff --git a/tailbone/views/features.py b/tailbone/views/features.py new file mode 100644 index 00000000..d9417452 --- /dev/null +++ b/tailbone/views/features.py @@ -0,0 +1,115 @@ +# -*- 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/>. +# +################################################################################ +""" +Feature views +""" + +import colander +import markdown + +from tailbone import forms +from tailbone.views import View + + +class GenerateFeatureView(View): + """ + View for generating new feature source code + """ + + def __init__(self, request): + super(GenerateFeatureView, self).__init__(request) + self.handler = self.get_handler() + + def get_handler(self): + app = self.get_rattail_app() + handler = app.get_feature_handler() + return handler + + def __call__(self): + schema = self.handler.make_schema() + 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) + 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(): + + feature_type = self.request.POST['feature_type'] + feature = self.handler.get_feature(feature_type) + if not feature: + raise ValueError("Unknown feature type: {}".format(feature_type)) + + feature_form = feature_forms[feature.feature_key] + if feature_form.validate(): + context = dict(app_form.validated) + context.update(feature_form.validated) + result = self.handler.do_generate(feature, **context) + rendered_result = self.render_result(result) + + context = { + 'index_title': "Generate Feature", + 'handler': self.handler, + 'app_form': app_form, + 'feature_type': feature_type, + 'feature_forms': feature_forms, + 'result': result, + 'rendered_result': rendered_result, + } + + return context + + def render_result(self, result): + return markdown.markdown(result, extensions=['fenced_code', + 'codehilite']) + + @classmethod + def defaults(cls, config): + + # generate feature + config.add_tailbone_permission('common', 'common.generate_feature', + "Generate new feature source code") + config.add_route('generate_feature', '/generate-feature') + config.add_view(cls, route_name='generate_feature', + permission='common.generate_feature', + renderer='/generate_feature.mako') + + +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 new file mode 100644 index 00000000..b0c81b45 --- /dev/null +++ b/tailbone/views/filemon.py @@ -0,0 +1,74 @@ +# -*- 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/>. +# +################################################################################ +""" +FileMon Views +""" + +from __future__ import unicode_literals, absolute_import + +import subprocess +import logging + +from tailbone.views import View + + +log = logging.getLogger(__name__) + + +class FilemonView(View): + """ + Misc. views for Filemon...(for now) + """ + + def restart(self): + cmd = self.rattail_config.getlist('tailbone', 'filemon.restart', default='/bin/sleep 3') # simulate by default + log.debug("attempting filemon restart with command: %s", cmd) + try: + subprocess.check_call(cmd) + except Except as error: + self.request.session.flash("FileMon daemon could not be restarted: {}".format(error), 'error') + else: + self.request.session.flash("FileMon daemon has been restarted.") + return self.redirect(self.request.get_referrer(default=self.request.route_url('datasyncchanges'))) + + @classmethod + def defaults(cls, config): + + # fix permission group title + config.add_tailbone_permission_group('filemon', label="FileMon") + + # restart filemon + config.add_tailbone_permission('filemon', 'filemon.restart', label="Restart FileMon Daemon") + config.add_route('filemon.restart', '/filemon/restart', request_method='POST') + config.add_view(cls, attr='restart', route_name='filemon.restart', permission='filemon.restart') + + +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 c117c795..34211c30 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -21,187 +21,17 @@ # ################################################################################ """ -Views for handheld batches +(DEPRECATED) Views for handheld batches """ -from __future__ import unicode_literals, absolute_import +import warnings -import os - -from rattail import enum -from rattail.db import model -from rattail.util import OrderedDict - -import formalchemy as fa -import formencode as fe -from webhelpers2.html import tags - -from tailbone import forms -from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView2 as FileBatchMasterView - - -ACTION_OPTIONS = OrderedDict([ - ('make_label_batch', "Make a new Label Batch"), - ('make_inventory_batch', "Make a new Inventory Batch"), -]) - - -class ExecutionOptions(fe.Schema): - allow_extra_fields = True - filter_extra_fields = True - action = fe.validators.OneOf(ACTION_OPTIONS) - - -class InventoryBatchFieldRenderer(fa.FieldRenderer): - """ - Renderer for handheld batch's "inventory batch" field. - """ - - def render_readonly(self, **kwargs): - batch = self.raw_value - if batch: - return tags.link_to( - batch.id_str, - self.request.route_url('batch.inventory.view', uuid=batch.uuid)) - return '' - - - -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', - ] - - row_grid_columns = [ - 'sequence', - 'upc', - 'brand_name', - 'description', - 'size', - 'cases', - 'units', - 'status_code', - ] - - 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 _preconfigure_fieldset(self, fs): - super(HandheldBatchView, self)._preconfigure_fieldset(fs) - device_types = OrderedDict(sorted(self.enum.HANDHELD_DEVICE_TYPE.items(), - key=lambda item: item[1])) - fs.device_type.set(renderer=forms.renderers.EnumFieldRenderer(device_types)) - - def configure_fieldset(self, fs): - if self.creating: - fs.configure( - include=[ - fs.filename, - fs.device_type, - fs.device_name, - ]) - - else: - fs.configure( - include=[ - fs.id, - fs.device_type, - fs.device_name, - fs.filename, - fs.created, - fs.created_by, - fs.rowcount, - fs.status_code, - fs.executed, - fs.executed_by, - ]) - - if self.viewing and fs.model.inventory_batch: - fs.append(fa.Field('inventory_batch', value=fs.model.inventory_batch, renderer=InventoryBatchFieldRenderer)) - - 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('upc', "UPC") - 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 _preconfigure_row_fieldset(self, fs): - super(HandheldBatchView, self)._preconfigure_row_fieldset(fs) - fs.upc.set(readonly=True, label="UPC", renderer=forms.renderers.GPCFieldRenderer, - attrs={'link': lambda r: self.request.route_url('products.view', uuid=r.product_uuid)}) - fs.brand_name.set(readonly=True) - fs.description.set(readonly=True) - fs.size.set(readonly=True) - - def configure_row_fieldset(self, fs): - fs.configure( - include=[ - fs.sequence, - fs.upc, - fs.brand_name, - fs.description, - fs.size, - fs.status_code, - fs.cases, - fs.units, - ]) - - def get_exec_options_kwargs(self, **kwargs): - kwargs['ACTION_OPTIONS'] = list(ACTION_OPTIONS.iteritems()) - return kwargs - - 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): - 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 new file mode 100644 index 00000000..af626ef3 --- /dev/null +++ b/tailbone/views/ifps.py @@ -0,0 +1,101 @@ +# -*- 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/>. +# +################################################################################ +""" +IFPS Views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model + +from tailbone.views import MasterView + + +class IFPS_PLUView(MasterView): + """ + Master view for the Department class. + """ + model_class = model.IFPS_PLU + route_prefix = 'ifps_plus' + url_prefix = '/ifps-plu-codes' + results_downloadable = True + has_versions = True + + labels = { + 'plu': "PLU", + 'gpc': "GPC", + 'aka': "AKA", + 'measurements_north_america': "Measurements (North America)", + 'measurements_rest_of_world': "Measurements (rest of world)", + } + + grid_columns = [ + 'plu', + 'category', + 'commodity', + 'variety', + 'size', + 'botanical_name', + 'revision_date', + ] + + def configure_grid(self, g): + super(IFPS_PLUView, self).configure_grid(g) + + g.filters['plu'].default_active = True + g.filters['plu'].default_verb = 'equal' + + g.filters['commodity'].default_active = True + g.filters['commodity'].default_verb = 'contains' + + g.set_sort_defaults('plu') + + # variety + # this is actually a TEXT field, so potentially large + g.set_renderer('variety', self.render_truncated_value) + + g.set_link('plu') + g.set_link('commodity') + g.set_link('variety') + + def configure_form(self, f): + super(IFPS_PLUView, self).configure_form(f) + + if self.creating: + f.remove('revision_date', + 'date_added') + else: + f.set_readonly('revision_date') + f.set_readonly('date_added') + + + +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 2435abcc..4622fa9f 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -26,368 +26,56 @@ Views for inventory batches from __future__ import unicode_literals, absolute_import -import re +from rattail.db import model -import six +import colander -from rattail import pod -from rattail.db import model, api -from rattail.time import localtime -from rattail.gpc import GPC -from rattail.util import pretty_quantity - -import formalchemy as fa -import formencode as fe - -from tailbone import forms -from tailbone.views.batch import BatchMasterView2 as BatchMasterView +from tailbone.views import MasterView -class InventoryBatchView(BatchMasterView): +class InventoryAdjustmentReasonView(MasterView): """ - Master view for inventory batches. + Master view for inventory adjustment reasons. """ - model_class = model.InventoryBatch - model_title_plural = "Inventory Batches" - default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' - route_prefix = 'batch.inventory' - url_prefix = '/batch/inventory' - index_title = "Inventory" - creatable = False - mobile_creatable = True - mobile_rows_creatable = True + model_class = model.InventoryAdjustmentReason + route_prefix = 'invadjust_reasons' + url_prefix = '/inventory-adjustment-reasons' grid_columns = [ - 'id', - 'created', - 'created_by', + 'code', 'description', - 'mode', - 'rowcount', - 'total_cost', - 'executed', - 'executed_by', - ] - - model_row_class = model.InventoryBatchRow - rows_editable = True - - row_grid_columns = [ - 'sequence', - 'upc', - 'item_id', - 'brand_name', - 'description', - 'size', - 'previous_units_on_hand', - 'cases', - 'units', - 'unit_cost', - 'total_cost', - 'status_code', + 'hidden', ] def configure_grid(self, g): - super(InventoryBatchView, self).configure_grid(g) - g.set_enum('mode', self.enum.INVENTORY_MODE) - g.set_type('total_cost', 'currency') - g.set_label('mode', "Count Mode") + super(InventoryAdjustmentReasonView, self).configure_grid(g) + g.set_sort_defaults('code') - def render_mobile_listitem(self, batch, i): - return "({}) {} rows - {}, {}".format( - batch.id_str, - "?" if batch.rowcount is None else batch.rowcount, - batch.created_by, - localtime(self.request.rattail_config, batch.created, from_utc=True).strftime('%Y-%m-%d')) + def configure_form(self, f): + super(InventoryAdjustmentReasonView, self).configure_form(f) - def editable_instance(self, batch): - return True + # code + f.set_validator('code', self.unique_code) - def mutable_batch(self, batch): - return not batch.executed and not batch.complete and batch.mode != self.enum.INVENTORY_MODE_ZERO_ALL + def unique_code(self, node, value): + query = self.Session.query(model.InventoryAdjustmentReason)\ + .filter(model.InventoryAdjustmentReason.code == value) + if self.editing: + reason = self.get_instance() + query = query.filter(model.InventoryAdjustmentReason.uuid != reason.uuid) + if query.count(): + raise colander.Invalid(node, "Code must be unique") - def allow_worksheet(self, batch): - return self.mutable_batch(batch) - - def _preconfigure_fieldset(self, fs): - super(InventoryBatchView, self)._preconfigure_fieldset(fs) - permission_prefix = self.get_permission_prefix() - - modes = dict(self.enum.INVENTORY_MODE) - if not self.request.has_perm('{}.create.replace'.format(permission_prefix)): - if hasattr(self.enum, 'INVENTORY_MODE_REPLACE'): - modes.pop(self.enum.INVENTORY_MODE_REPLACE, None) - if hasattr(self.enum, 'INVENTORY_MODE_REPLACE_ADJUST'): - modes.pop(self.enum.INVENTORY_MODE_REPLACE_ADJUST, None) - if not self.request.has_perm('{}.create.zero'.format(permission_prefix)): - if hasattr(self.enum, 'INVENTORY_MODE_ZERO_ALL'): - modes.pop(self.enum.INVENTORY_MODE_ZERO_ALL, None) - - fs.mode.set(renderer=forms.renderers.EnumFieldRenderer(modes), - label="Count Mode", required=True, attrs={'auto-enhance': 'true'}) - # if len(modes) == 1: - # fs.mode.set(readonly=True) - - fs.total_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) - fs.append(fa.Field('handheld_batches', renderer=forms.renderers.HandheldBatchesFieldRenderer, readonly=True, - value=lambda b: b._handhelds)) - - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id, - fs.description, - fs.created, - fs.created_by, - fs.handheld_batches, - fs.mode, - fs.reason_code, - fs.rowcount, - fs.complete, - fs.executed, - fs.executed_by, - ]) - if not self.creating: - fs.mode.set(readonly=True) - fs.reason_code.set(readonly=True) - - def row_editable(self, row): - return self.mutable_batch(row.batch) - - def row_deletable(self, row): - return self.mutable_batch(row.batch) - - def save_edit_row_form(self, form): - row = form.fieldset.model - batch = row.batch - if batch.total_cost is not None and row.total_cost is not None: - batch.total_cost -= row.total_cost - return super(InventoryBatchView, self).save_edit_row_form(form) - - def delete_row(self): - row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['uuid']) - if not row: - raise self.notfound() - batch = row.batch - if batch.total_cost is not None and row.total_cost is not None: - batch.total_cost -= row.total_cost - return super(InventoryBatchView, self).delete_row() - - def configure_mobile_fieldset(self, fs): - fs.configure(include=[ - fs.mode, - fs.reason_code, - fs.rowcount, - fs.complete, - fs.executed, - fs.executed_by, - ]) - batch = fs.model - if self.creating: - del fs.rowcount - if not batch.executed: - del [fs.executed, fs.executed_by] - if not batch.complete: - del fs.complete - else: - del fs.complete - - # TODO: this view can create new rows, with only a GET query. that should - # probably be changed to require POST; for now we just require the "create - # batch row" perm and call it good.. - def mobile_row_from_upc(self): - """ - Locate and/or create a row within the batch, according to the given - product UPC, then redirect to the row view page. - """ - batch = self.get_instance() - row = None - upc = self.request.GET.get('upc', '').strip() - upc = re.sub(r'\D', '', upc) - if upc: - - # try to locate general product by UPC; add to batch either way - provided = GPC(upc, calc_check_digit=False) - checked = GPC(upc, calc_check_digit='upc') - product = api.get_product_by_upc(self.Session(), provided) - if not product: - product = api.get_product_by_upc(self.Session(), checked) - row = model.InventoryBatchRow() - if product: - row.product = product - row.upc = product.upc - else: - row.upc = provided # TODO: why not 'checked' instead? how to choose? - row.description = "(unknown product)" - self.handler.add_row(batch, row) - - self.Session.flush() - return self.redirect(self.mobile_row_route_url('view', uuid=row.uuid)) - - def template_kwargs_view_row(self, **kwargs): - row = kwargs['instance'] - kwargs['product_image_url'] = pod.get_image_url(self.rattail_config, row.upc) - return kwargs - - def get_batch_kwargs(self, batch, mobile=False): - kwargs = super(InventoryBatchView, self).get_batch_kwargs(batch, mobile=False) - kwargs['mode'] = batch.mode - kwargs['complete'] = False - kwargs['reason_code'] = batch.reason_code - return kwargs - - def get_mobile_row_data(self, batch): - # we want newest on top, for inventory batch rows - return self.get_row_data(batch)\ - .order_by(self.model_row_class.sequence.desc()) - - # TODO: ugh, the hackiness. needs a refactor fo sho - def mobile_view_row(self): - """ - Mobile view for inventory batch rows. Note that this also handles - updating a row...ugh. - """ - self.viewing = True - row = self.get_row_instance() - parent = self.get_parent(row) - form = self.make_mobile_row_form(row) - context = { - 'row': row, - 'instance': row, - 'instance_title': self.get_row_instance_title(row), - 'parent_model_title': self.get_model_title(), - 'parent_title': self.get_instance_title(parent), - 'parent_url': self.get_action_url('view', parent, mobile=True), - 'product_image_url': pod.get_image_url(self.rattail_config, row.upc), - 'form': form, - } - - if self.request.has_perm('{}.edit'.format(self.get_row_permission_prefix())): - update_form = forms.SimpleForm(self.request, schema=InventoryForm) - if update_form.validate(): - row = update_form.data['row'] - cases = update_form.data['cases'] - units = update_form.data['units'] - if cases: - row.cases = cases - row.units = None - elif units: - row.cases = None - row.units = units - self.handler.refresh_row(row) - return self.redirect(self.request.route_url('mobile.{}.view'.format(self.get_route_prefix()), uuid=row.batch_uuid)) - - return self.render_to_response('view_row', context, mobile=True) - - def get_row_instance_title(self, row): - if row.upc: - return row.upc.pretty() - if row.item_id: - return row.item_id - return "row {}".format(row.sequence) - - def configure_row_grid(self, g): - super(InventoryBatchView, self).configure_row_grid(g) - - g.set_type('previous_units_on_hand', 'quantity') - g.set_type('cases', 'quantity') - g.set_type('units', 'quantity') - g.set_type('unit_cost', 'currency') - g.set_type('total_cost', 'currency') - - # TODO: i guess row grids don't support this properly yet? - # g.set_link('upc') - # g.set_link('item_id') - # g.set_link('description') - - g.set_label('upc', "UPC") - g.set_label('brand_name', "Brand") - g.set_label('status_code', "Status") - g.set_label('previous_units_on_hand', "Prev. On Hand") - - def row_grid_extra_class(self, row, i): - if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: - return 'warning' - - def render_mobile_row_listitem(self, row, i): - description = row.product.full_description if row.product else row.description - unit_uom = 'LB' if row.product and row.product.weighed else 'EA' - qty = "{} {}".format(pretty_quantity(row.cases or row.units), 'CS' if row.cases else unit_uom) - return "({}) {} - {}".format(row.upc.pretty(), description, qty) - - def _preconfigure_row_fieldset(self, fs): - super(InventoryBatchView, self)._preconfigure_row_fieldset(fs) - fs.upc.set(readonly=True, label="UPC", renderer=forms.renderers.GPCFieldRenderer, - attrs={'link': lambda r: self.request.route_url('products.view', uuid=r.product_uuid)}) - fs.item_id.set(readonly=True) - fs.brand_name.set(readonly=True) - fs.description.set(readonly=True) - fs.size.set(readonly=True) - fs.previous_units_on_hand.set(label="Prev. On Hand") - fs.cases.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.unit_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) - fs.total_cost.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) - - def configure_row_fieldset(self, fs): - fs.configure( - include=[ - fs.sequence, - fs.upc, - fs.brand_name, - fs.description, - fs.size, - fs.status_code, - fs.previous_units_on_hand, - fs.cases, - fs.units, - fs.unit_cost, - fs.total_cost, - ]) - - @classmethod - def defaults(cls, config): - cls._batch_defaults(config) - cls._defaults(config) - cls._inventory_defaults(config) - - @classmethod - def _inventory_defaults(cls, config): - model_key = cls.get_model_key() - model_title = cls.get_model_title() - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() - permission_prefix = cls.get_permission_prefix() - - # mobile - make new row from UPC - config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix), - permission='{}.create_row'.format(permission_prefix)) - - # extra perms for creating batches per "mode" - config.add_tailbone_permission(permission_prefix, '{}.create.replace'.format(permission_prefix), - "Create new {} with 'replace' mode".format(model_title)) - config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix), - "Create new {} with 'zero' mode".format(model_title)) +# TODO: deprecate / remove this +InventoryAdjustmentReasonsView = InventoryAdjustmentReasonView -class ValidBatchRow(forms.validators.ModelValidator): - model_class = model.InventoryBatchRow +def defaults(config, **kwargs): + base = globals() - def _to_python(self, value, state): - row = super(ValidBatchRow, self)._to_python(value, state) - if row.batch.executed: - raise fe.Invalid("Batch has already been executed", value, state) - return row - - -class InventoryForm(forms.Schema): - allow_extra_fields = True - filter_extra_fields = True - row = ValidBatchRow() - cases = fe.validators.Number() - units = fe.validators.Number() + InventoryAdjustmentReasonView = kwargs.get('InventoryAdjustmentReasonView', base['InventoryAdjustmentReasonView']) + InventoryAdjustmentReasonView.defaults(config) def includeme(config): - InventoryBatchView.defaults(config) + defaults(config) diff --git a/tailbone/views/labels/batch.py b/tailbone/views/labels/batch.py index 55b9d417..e9d2971b 100644 --- a/tailbone/views/labels/batch.py +++ b/tailbone/views/labels/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -21,146 +21,18 @@ # ################################################################################ """ -Views for label batches +(Deprecated!) Views for label batches + +Please use `tailbone.views.batch.labels` instead. """ -from __future__ import unicode_literals, absolute_import - -from rattail.db import model - -import formalchemy as fa - -from tailbone import forms -from tailbone.views.batch import BatchMasterView2 as BatchMasterView - - -class LabelBatchView(BatchMasterView): - """ - Master view for label batches. - """ - model_class = model.LabelBatch - model_row_class = model.LabelBatchRow - default_handler_spec = 'rattail.batch.labels:LabelBatchHandler' - model_title_plural = "Label Batches" - route_prefix = 'labels.batch' - url_prefix = '/labels/batches' - creatable = False - editable = False - rows_editable = True - cloneable = True - - row_grid_columns = [ - 'sequence', - 'upc', - 'brand_name', - 'description', - 'size', - 'regular_price', - 'sale_price', - 'label_profile', - 'label_quantity', - 'status_code', - ] - - def _preconfigure_fieldset(self, fs): - super(LabelBatchView, self)._preconfigure_fieldset(fs) - fs.append(fa.Field('handheld_batches', renderer=forms.renderers.HandheldBatchesFieldRenderer, readonly=True, - value=lambda b: b._handhelds)) - - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - fs.handheld_batches, - fs.rowcount, - fs.executed, - fs.executed_by, - ]) - batch = fs.model - if self.viewing and not batch._handhelds: - del fs.handheld_batches - - def configure_row_grid(self, g): - super(LabelBatchView, self).configure_row_grid(g) - g.set_label('upc', "UPC") - g.set_label('brand_name', "Brand") - g.set_label('regular_price', "Reg Price") - g.set_label('label_profile', "Label Type") - g.set_label('label_quantity', "Qty") - - def row_grid_extra_class(self, row, i): - if row.status_code != row.STATUS_OK: - return 'warning' - - def _preconfigure_row_fieldset(self, fs): - fs.sequence.set(readonly=True) - fs.product.set(readonly=True) - fs.upc.set(readonly=True, label="UPC") - fs.brand_name.set(readonly=True) - fs.description.set(readonly=True) - fs.size.set(readonly=True) - fs.department_number.set(readonly=True) - fs.department_name.set(readonly=True) - fs.regular_price.set(readonly=True) - fs.pack_quantity.set(readonly=True) - fs.pack_price.set(readonly=True) - fs.sale_price.set(readonly=True) - fs.sale_start.set(readonly=True) - fs.sale_stop.set(readonly=True) - fs.vendor_id.set(readonly=True, label="Vendor ID") - fs.vendor_name.set(readonly=True) - fs.vendor_item_code.set(readonly=True) - fs.case_quantity.set(readonly=True) - fs.status_code.set(readonly=True) - fs.status_text.set(readonly=True) - - fs.label_profile.set(label="Label Type") - - def configure_row_fieldset(self, fs): - if self.viewing: - fs.configure( - include=[ - fs.sequence, - fs.upc, - fs.brand_name, - fs.description, - fs.size, - fs.department_number, - fs.department_name, - fs.regular_price, - fs.pack_quantity, - fs.pack_price, - fs.sale_price, - fs.sale_start, - fs.sale_stop, - fs.vendor_id, - fs.vendor_name, - fs.vendor_item_code, - fs.case_quantity, - fs.label_profile, - fs.label_quantity, - fs.status_code, - fs.status_text, - ]) - - elif self.editing: - fs.configure( - include=[ - fs.sequence, - fs.upc, - fs.product, - fs.department_number, - fs.department_name, - fs.regular_price, - fs.sale_price, - fs.label_profile, - fs.label_quantity, - fs.status_code, - fs.status_text, - ]) +import warnings def includeme(config): - LabelBatchView.defaults(config) + + warnings.warn("The `tailbone.views.labels.batch` module is deprecated, " + "please use `tailbone.views.batch.labels` instead.", + DeprecationWarning, stacklevel=2) + + config.include('tailbone.views.batch.labels') diff --git a/tailbone/views/labels/profiles.py b/tailbone/views/labels/profiles.py index 3c06d8c8..fa878448 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,18 +24,15 @@ Label Profile Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model -from pyramid.httpexceptions import HTTPFound +import colander from tailbone import forms -from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView -class ProfilesView(MasterView): +class LabelProfileView(MasterView): """ Master view for the LabelProfile model. """ @@ -49,76 +46,146 @@ class ProfilesView(MasterView): 'code', 'description', 'visible', + 'sync_me', ] + form_fields = [ + 'ordinal', + 'code', + 'description', + 'printer_spec', + 'formatter_spec', + 'format', + 'visible', + '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(ProfilesView, self).configure_grid(g) - g.default_sortkey = 'ordinal' + super(LabelProfileView, self).configure_grid(g) + g.set_sort_defaults('ordinal') g.set_type('visible', 'boolean') g.set_link('code') g.set_link('description') - def configure_fieldset(self, fs): - fs.printer_spec.set(renderer=forms.renderers.StrippedTextFieldRenderer) - fs.formatter_spec.set(renderer=forms.renderers.StrippedTextFieldRenderer) - fs.format.set(renderer=forms.renderers.CodeTextAreaFieldRenderer) - fs.configure( - include=[ - fs.ordinal, - fs.code, - fs.description, - fs.printer_spec, - fs.formatter_spec, - fs.format, - fs.visible, - ]) + def configure_form(self, f): + super(LabelProfileView, self).configure_form(f) + + # 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 except NotImplementedError: pass + def make_printer_settings_form(self, profile, printer): + schema = colander.Schema() -def printer_settings(request): - uuid = request.matchdict['uuid'] - profile = Session.query(model.LabelProfile).get(uuid) if uuid else None - if not profile: - return HTTPFound(location=request.route_url('labelprofiles')) + for name, label in printer.required_settings.items(): + node = colander.SchemaNode(colander.String(), + name=name, + title=label, + default=self.label_handler.get_printer_setting(profile, name)) + schema.add(node) - read_profile = HTTPFound(location=request.route_url( - 'labelprofiles.view', uuid=profile.uuid)) + form = forms.Form(schema=schema, request=self.request, + model_instance=profile, + # TODO: ugh, this is necessary to avoid some logic + # which assumes a ColanderAlchemy schema i think? + appstruct=None) + form.cancel_url = self.get_action_url('view', profile) + form.auto_disable_cancel = True - printer = profile.get_printer(request.rattail_config) - if not printer: - request.session.flash("Label profile \"%s\" does not have a functional " - "printer spec." % profile) - return read_profile - if not printer.required_settings: - request.session.flash("Printer class for label profile \"%s\" does not " - "require any settings." % profile) - return read_profile + form.insert_before(schema.children[0].name, 'label_profile') + form.set_readonly('label_profile') + form.set_renderer('label_profile', lambda p, f: p.description) - if request.method == 'POST': - for setting in printer.required_settings: - if setting in request.POST: - profile.save_printer_setting(setting, request.POST[setting]) - return read_profile + form.insert_after('label_profile', 'printer_spec') + form.set_readonly('printer_spec') + form.set_renderer('printer_spec', lambda p, f: p.printer_spec) - return {'profile': profile, 'printer': printer} + return form + + def printer_settings(self): + """ + View for editing extended Printer Settings, for a given Label Profile. + """ + profile = self.get_instance() + redirect = self.redirect(self.get_action_url('view', profile)) + + 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 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 redirect + + form = self.make_printer_settings_form(profile, printer) + + # TODO: should use form.validate() here + if self.request.method == 'POST': + for setting in printer.required_settings: + if setting in self.request.POST: + self.label_handler.save_printer_setting( + profile, setting, self.request.POST[setting]) + return redirect + + return self.render_to_response('printer', { + 'form': form, + 'dform': form.make_deform_form(), + 'profile': profile, + 'printer': printer, + }) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._labelprofile_defaults(config) + + @classmethod + def _labelprofile_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() + + # edit printer settings + config.add_route('{}.printer_settings'.format(route_prefix), '{}/{{{}}}/printer'.format(url_prefix, model_key)) + config.add_view(cls, attr='printer_settings', route_name='{}.printer_settings'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) + +# TODO: deprecate / remove this +ProfilesView = LabelProfileView + + +def defaults(config, **kwargs): + base = globals() + + LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView']) + LabelProfileView.defaults(config) def includeme(config): - ProfilesView.defaults(config) - - # edit printer settings - config.add_route('labelprofiles.printer_settings', '/labels/profiles/{uuid}/printer') - config.add_view(printer_settings, route_name='labelprofiles.printer_settings', - renderer='/labels/profiles/printer.mako', - permission='labelprofiles.edit') + 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 4dad973a..21a5e58f 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.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,38 +24,50 @@ Model Master View """ -from __future__ import unicode_literals, absolute_import - +import io import os +import csv +import datetime +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 -from rattail.time import localtime #, make_utc +from rattail.util import simple_error from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter +from rattail.excel import ExcelWriter +from rattail.gpc import GPC -import formalchemy as fa +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.progress import SessionProgress +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. @@ -64,32 +76,79 @@ class MasterView(View): pageable = True checkboxes = False + # set to True to allow user to click "anywhere" in a row in order + # to toggle its checkbox + clicking_row_checks_box = False + + # set to True in order to encode search values as utf-8 + use_byte_string_filters = False + + # set to True if all timestamps are "local" instead of UTC + has_local_times = False + listable = True + sortable = True + results_downloadable = False results_downloadable_csv = False + results_downloadable_xlsx = False + results_rows_downloadable = False creatable = True + show_create_link = True 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 - supports_mobile = False - mobile_creatable = False - mobile_filterable = False + # set to True to add "View *global* Objects" permission, and + # expose / leverage the ``local_only`` object flag + secure_global_objects = False + + # quickie (search) + supports_quickie_search = False + + # set to True to declare model as "contact" + is_contact = False listing = False creating = False + creates_multiple = False viewing = False editing = False deleting = False + executing = False + cloning = False + configuring = False has_pk_fields = False + has_image = False + has_thumbnail = False + + # can set this to true, and set type key as needed, and implement some + # other things also, to get a DB picker in the header for all views + supports_multiple_engines = False + engine_type_key = 'rattail' + SessionDefault = None + SessionExtras = {} row_attrs = {} cell_attrs = {} @@ -98,36 +157,160 @@ 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_filterable = True + 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 = 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 - mobile_rows_creatable = False - mobile_rows_filterable = False - mobile_rows_viewable = False - mobile_rows_editable = False + row_labels = { + 'upc': "UPC", + } @property def Session(self): """ - SQLAlchemy scoped session to use when querying the database. Defaults - to ``tailbone.db.Session``. + Which session we return may depend on user's "current" engine. """ + if self.supports_multiple_engines: + dbkey = self.get_current_engine_dbkey() + if dbkey != 'default' and dbkey in self.SessionExtras: + return self.SessionExtras[dbkey] + + if self.SessionDefault: + return self.SessionDefault + from tailbone.db import Session return Session + def make_isolated_session(self): + """ + This method should return a newly-created SQLAlchemy Session instance. + The use case here is primarily for secondary threads, which may be + employed for long-running processes such as executing a batch. The + session returned should *not* have any web hooks to auto-commit with + the request/response cycle etc. It should just be a plain old session, + "isolated" from the rest of the web app in a sense. + + So whereas ``self.Session`` by default will return a reference to + ``tailbone.db.Session``, which is a "scoped" session wrapper specific + to the current thread (one per request), this method should instead + return e.g. a new independent ``rattail.db.Session`` instance. + """ + app = self.get_rattail_app() + return app.make_session() + + @classmethod + def get_grid_factory(cls): + """ + Returns the grid factory or class which is to be used when creating new + grid instances. + """ + 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): + """ + Returns the grid factory or class which is to be used when creating new + row grid instances. + """ + return getattr(cls, 'row_grid_factory', grids.Grid) + + @classmethod + def get_version_grid_factory(cls): + """ + Returns the grid factory or class which is to be used when creating new + version grid instances. + """ + return getattr(cls, 'version_grid_factory', grids.Grid) + + def set_labels(self, obj): + labels = self.collect_labels() + for key, label in labels.items(): + obj.set_label(key, label) + + def collect_labels(self): + """ + 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'): + labels.update(cls.labels) + return labels + + def get_class_hierarchy(self): + return get_class_hierarchy(self.__class__) + + def set_row_labels(self, obj): + labels = self.collect_row_labels() + for key, label in labels.items(): + obj.set_label(key, label) + + def collect_row_labels(self): + """ + Collect all row labels defined within the master class hierarchy. + """ + labels = {} + hierarchy = self.get_class_hierarchy() + for cls in hierarchy: + if hasattr(cls, 'row_labels'): + labels.update(cls.row_labels) + return labels + + def has_perm(self, name): + """ + Convenience function which returns boolean which should indicate + whether the current user has been granted the named permission. Note + that this method actually assembles the permission name, using the + ``name`` provided, but also :meth:`get_permission_prefix()`. + """ + 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 ############################## @@ -140,150 +323,786 @@ 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() # 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'): - self.request.response.content_type = b'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) - return self.render_to_response('index', {'grid': grid}) + context = { + 'index_url': None, # nb. avoid title link since this *is* the index + 'grid': grid, + } - def mobile_index(self): - """ - Mobile "home" page for the data model - """ - self.listing = True - grid = self.make_mobile_grid() - return self.render_to_response('index', {'grid': grid}, mobile=True) + if self.results_downloadable and self.has_perm('download_results'): + route_prefix = self.get_route_prefix() + context['download_results_path'] = self.request.session.pop( + '{}.results.generated'.format(route_prefix), None) + available = self.download_results_fields_available() + context['download_results_fields_available'] = available + context['download_results_fields_default'] = self.download_results_fields_default(available) - @classmethod - def get_mobile_grid_key(cls): - """ - Must return a unique "config key" for the mobile grid, for sort/filter - purposes etc. (It need only be unique among *mobile* grids.) Instead - of overriding this, you can set :attr:`mobile_grid_key`. Default is - the value returned by :meth:`get_route_prefix()`. - """ - if hasattr(cls, 'mobile_grid_key'): - return cls.mobile_grid_key - return 'mobile.{}'.format(cls.get_route_prefix()) + if self.has_rows and self.results_rows_downloadable and self.has_perm('download_results_rows'): + route_prefix = self.get_route_prefix() + context['download_results_rows_path'] = self.request.session.pop( + '{}.results_rows.generated'.format(route_prefix), None) + available = self.download_results_fields_available() + context['download_results_rows_fields_available'] = available + context['download_results_rows_fields_default'] = self.download_results_rows_fields_default(available) - def get_mobile_data(self, session=None): - """ - Must return the "raw" / full data set for the mobile grid. This data - should *not* yet be sorted or filtered in any way; that happens later. - Default is the value returned by :meth:`get_data()`, in which case all - records visible in the traditional view, are visible in mobile too. - """ - return self.get_data(session=session) + self.before_render_index() + return self.render_to_response('index', context) - def make_mobile_filters(self): + def before_render_index(self): """ - Returns a set of filters for the mobile grid, if applicable. + Perform any needed logic just prior to rendering the index + response. Note that this logic is invoked only when rendering + the main index page, but *not* invoked when refreshing partial + grid contents etc. """ - def make_mobile_row_filters(self): + def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs): """ - Returns a set of filters for the mobile row grid, if applicable. + Creates a new grid instance + """ + if factory is None: + factory = self.get_grid_factory() + if key is None: + key = self.get_grid_key() + if data is None: + data = self.get_data(session=session) + if columns is None: + columns = self.get_grid_columns() + + kwargs = self.make_grid_kwargs(**kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) + self.configure_grid(grid) + grid.load_settings() + return grid + + def get_effective_data(self, session=None, **kwargs): + """ + Convenience method which returns the "effective" data for the master + grid, filtered and sorted to match what would show on the UI, but not + paged etc. + """ + if session is None: + session = self.Session() + kwargs.setdefault('paginated', False) + grid = self.make_grid(session=session, **kwargs) + return grid.get_visible_data() + + def get_grid_columns(self): + """ + Returns the default list of grid column names. This may return + ``None``, in which case the grid will generate its own default list. + """ + if hasattr(self, 'grid_columns'): + return self.grid_columns + + def make_grid_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new grid instances. + """ + checkboxes = kwargs.get('checkboxes', self.checkboxes) + if not checkboxes and self.mergeable and self.has_perm('merge'): + checkboxes = True + if not checkboxes and self.supports_set_enabled_toggle and self.has_perm('enable_disable_set'): + checkboxes = True + if not checkboxes and self.set_deletable and self.has_perm('delete_set'): + checkboxes = True + + defaults = { + 'model_class': getattr(self, 'model_class', None), + 'model_title': self.get_model_title(), + 'model_title_plural': self.get_model_title_plural(), + 'width': 'full', + 'filterable': self.filterable, + 'use_byte_string_filters': self.use_byte_string_filters, + 'sortable': self.sortable, + '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 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. + """ + self.set_labels(grid) + + # hide "local only" grid filter, unless global access allowed + if self.secure_global_objects: + if not self.has_perm('view_global'): + 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 mobile_listitem_field(self): + def get_quickie_url(self): + route_prefix = self.get_route_prefix() + return self.request.route_url('{}.quickie'.format(route_prefix)) + + def get_quickie_perm(self): + permission_prefix = self.get_permission_prefix() + return '{}.quickie'.format(permission_prefix) + + def get_quickie_placeholder(self): + pass + + def quickie(self): """ - Must return a FormAlchemy field to be appended to grid, or ``None`` if - none is desired. + Quickie search - tries to do a simple lookup based on a key + value. If a record is found, user is redirected to its view. """ - return fa.Field('listitem', value=lambda obj: obj, - renderer=self.mobile_listitem_renderer()) + 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()) - def mobile_listitem_renderer(self): + 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): """ - Must return a FormAlchemy field renderer callable for the mobile grid's - list item field. + Make and return a new (configured) rows grid instance. """ - master = self + instance = kwargs.pop('instance', None) + if not instance: + instance = self.get_instance() - class ListItemRenderer(fa.FieldRenderer): + if factory is None: + factory = self.get_row_grid_factory() + if key is None: + key = self.get_row_grid_key() + if data is None: + data = self.get_row_data(instance) + if columns is None: + columns = self.get_row_grid_columns() - def render_readonly(self, **kwargs): - obj = self.raw_value - if obj is None: - return '' - title = master.get_instance_title(obj) - url = master.get_action_url('view', obj, mobile=True) - return tags.link_to(title, url) + kwargs = self.make_row_grid_kwargs(**kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) + self.configure_row_grid(grid) + grid.load_settings() + return grid - return ListItemRenderer + def get_row_grid_columns(self): + if hasattr(self, 'row_grid_columns'): + return self.row_grid_columns - def mobile_row_listitem_renderer(self): + def make_row_grid_kwargs(self, **kwargs): """ - Must return a FormAlchemy field renderer callable for the mobile row - grid's list item field. + Return a dict of kwargs to be used when constructing a new rows grid. """ - master = self + defaults = { + 'model_class': self.model_row_class, + 'width': 'full', + 'filterable': self.rows_filterable, + 'use_byte_string_filters': self.use_byte_string_filters, + 'sortable': self.rows_sortable, + '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), + } - class ListItemRenderer(fa.FieldRenderer): - def render_readonly(self, **kwargs): - return master.render_mobile_row_listitem(self.raw_value, **kwargs) + if self.rows_default_pagesize: + defaults['pagesize'] = self.rows_default_pagesize - return ListItemRenderer + if self.has_rows and 'actions' not in defaults: + actions = [] - def create(self): + # view action + if self.rows_viewable: + 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'): + 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, + link_class='has-text-danger')) + defaults['delete_speedbump'] = self.rows_deletable_speedbump + + defaults['actions'] = actions + + defaults.update(kwargs) + return defaults + + def configure_row_grid(self, 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 + the given row object, or ``None``. + """ + + def make_version_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + """ + Creates a new version grid instance + """ + instance = kwargs.pop('instance', None) + if not instance: + instance = self.get_instance() + + if factory is None: + factory = self.get_version_grid_factory() + if key is None: + key = self.get_version_grid_key() + if data is None: + data = self.get_version_data(instance) + if columns is None: + columns = self.get_version_grid_columns() + + kwargs = self.make_version_grid_kwargs(**kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) + self.configure_version_grid(grid) + grid.load_settings() + return grid + + def get_version_grid_columns(self): + if hasattr(self, 'version_grid_columns'): + return self.version_grid_columns + # TODO + return [ + 'issued_at', + 'user', + 'remote_addr', + 'comment', + ] + + def make_version_grid_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when + constructing a new version grid. + """ + 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', + 'paginated': True, + 'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id), + } + if 'actions' not in kwargs: + url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) + defaults['actions'] = [ + self.make_action('view', icon='eye', url=url), + ] + defaults.update(kwargs) + return defaults + + def configure_version_grid(self, g): + g.set_sort_defaults('issued_at', 'desc') + g.set_renderer('comment', self.render_version_comment) + g.set_label('issued_at', "Changed") + g.set_label('user', "Changed by") + g.set_label('remote_addr', "IP Address") + + g.set_link('issued_at') + g.set_link('user') + g.set_link('comment') + + def render_version_comment(self, transaction, column): + return transaction.meta.get('comment', "") + + def create(self, form=None, template='create'): """ View for creating a new model record. """ self.creating = True - form = self.make_form(self.get_model_class()) + if form is None: + form = self.make_create_form() if self.request.method == 'POST': if self.validate_form(form): # let save_create_form() return alternate object if necessary - obj = self.save_create_form(form) or form.fieldset.model + obj = self.save_create_form(form) self.after_create(obj) self.flash_after_create(obj) return self.redirect_after_create(obj) context = {'form': form} if hasattr(form, 'make_deform_form'): context['dform'] = form.make_deform_form() - return self.render_to_response('create', context) + return self.render_to_response(template, context) - def mobile_create(self): + def make_create_form(self): + return self.make_form() + + def save_create_form(self, form): + uploads = self.normalize_uploads(form) + self.before_create(form) + with self.Session().no_autoflush: + obj = self.objectify(form, self.form_deserialized) + self.before_create_flush(obj, form) + self.Session.add(obj) + self.Session.flush() + self.process_uploads(obj, form, uploads) + 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 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: + 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): + pass + + def import_batch_from_file(self, handler_factory, model_name, + delete=False, schema=None, importer_host_title=None): + + handler = handler_factory(self.rattail_config) + + if not schema: + schema = forms.SimpleFileImport().bind(request=self.request) + form = forms.Form(schema=schema, request=self.request) + form.save_label = "Upload" + form.cancel_url = self.get_index_url() + if form.validate(): + + uploads = self.normalize_uploads(form) + filepath = uploads['filename']['temp_path'] + batches = handler.make_batches(model_name, + delete=delete, + # tdc_input_path=filepath, + # source_csv_path=filepath, + source_data_path=filepath, + runas_user=self.request.user) + batch = batches[0] + return self.redirect(self.request.route_url('batch.importer.view', uuid=batch.uuid)) + + if not importer_host_title: + importer_host_title = handler.host_title + + return self.render_to_response('import_file', { + 'form': form, + 'dform': form.make_deform_form(), + 'importer_host_title': importer_host_title, + }) + + def render_truncated_value(self, obj, field): """ - Mobile view for creating a new primary object + Simple renderer which truncates the (string) value to 100 chars. """ - self.creating = True - form = self.make_mobile_form(self.get_model_class()) - if self.request.method == 'POST': - if form.validate(): - # let save_create_form() return alternate object if necessary - obj = self.save_create_form(form) or form.fieldset.model - self.after_create(obj) - self.flash_after_create(obj) - return self.redirect_after_create(obj, mobile=True) - return self.render_to_response('create', {'form': form}, mobile=True) + value = getattr(obj, field) + if value is None: + return "" + value = str(value) + if len(value) > 100: + value = value[:100] + '...' + return value + + def render_id_str(self, obj, field): + """ + Render the ``id_str`` attribute value for the given object. + """ + return obj.id_str + + def render_as_is(self, obj, field): + return getattr(obj, field) + + def render_url(self, obj, field): + url = getattr(obj, field) + if url: + return tags.link_to(url, url, target='_blank') + + def render_html(self, obj, field): + html = getattr(obj, field) + if html: + return HTML.literal(html) + + def render_default_phone(self, obj, field): + """ + Render the "default" (first) phone number for the given contact. + """ + if obj.phones: + return obj.phones[0].number + + def render_default_email(self, obj, field): + """ + Render the "default" (first) email address for the given contact. + """ + if obj.emails: + return obj.emails[0].address + + # 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: + text = "({}) {}".format(store.id, store.name) + 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 = 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: + return "" + short = vendor.id or vendor.abbreviation + if short: + text = "({}) {}".format(short, vendor.name) + else: + 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: + return "" + text = "({}) {}".format(department.number, department.name) + url = self.request.route_url('departments.view', uuid=department.uuid) + return tags.link_to(text, url) + + def render_subdepartment(self, obj, field): + subdepartment = getattr(obj, field) + if not subdepartment: + return "" + text = "({}) {}".format(subdepartment.number, subdepartment.name) + 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: + return "" + text = "({}) {}".format(category.code, category.name) + url = self.request.route_url('categories.view', uuid=category.uuid) + return tags.link_to(text, url) + + def render_family(self, obj, field): + family = getattr(obj, field) + if not family: + return "" + text = "({}) {}".format(family.code, family.name) + url = self.request.route_url('families.view', uuid=family.uuid) + return tags.link_to(text, url) + + def render_report(self, obj, field): + report = getattr(obj, field) + if not report: + return "" + text = "({}) {}".format(report.code, report.name) + url = self.request.route_url('reportcodes.view', uuid=report.uuid) + return tags.link_to(text, url) + + def render_person(self, obj, field): + person = getattr(obj, field) + if not person: + return "" + 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 = str(user) + url = self.request.route_url('users.view', uuid=user.uuid) + return tags.link_to(text, url) + + def render_users(self, obj, field): + users = obj.users + if not users: + return "" + + items = [] + for user in users: + text = user.username + url = self.request.route_url('users.view', uuid=user.uuid) + 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 = 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 def flash_after_create(self, obj): self.request.session.flash("{} has been created: {}".format( self.get_model_title(), self.get_instance_title(obj))) - def save_create_form(self, form): - self.before_create(form) - form.save() - - def redirect_after_create(self, instance, mobile=False): + def redirect_after_create(self, instance, **kwargs): if self.populatable and self.should_populate(instance): - return self.redirect(self.get_action_url('populate', instance, mobile=mobile)) - return self.redirect(self.get_action_url('view', instance, mobile=mobile)) + return self.redirect(self.get_action_url('populate', instance)) + return self.redirect(self.get_action_url('view', instance)) def should_populate(self, obj): return True @@ -299,7 +1118,7 @@ class MasterView(View): # showing progress requires a separate thread; start that first key = '{}.populate'.format(route_prefix) - progress = SessionProgress(self.request, key) + progress = self.make_progress(key) thread = Thread(target=self.populate_thread, args=(obj.uuid, progress)) # TODO: uuid? thread.start() @@ -316,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: @@ -328,7 +1148,8 @@ class MasterView(View): if progress: progress.session.load() progress.session['error'] = True - progress.session['error_msg'] = "{}: {} {}".format(msg, error.__class__.__name__, error) + progress.session['error_msg'] = "{}: {}".format( + msg, simple_error(error)) progress.session.save() return @@ -366,13 +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'): - self.request.response.content_type = b'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, @@ -381,22 +1206,70 @@ 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: - 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) + def image(self): + """ + View which renders the object's image as a response. + """ + obj = self.get_instance() + image_bytes = self.get_image_bytes(obj) + if not image_bytes: + raise self.notfound() + # TODO: how to properly detect image type? + self.request.response.content_type = str('image/jpeg') + self.request.response.body = image_bytes + return self.request.response + + def get_image_bytes(self, obj): + raise NotImplementedError + + def thumbnail(self): + """ + View which renders the object's thumbnail image as a response. + """ + obj = self.get_instance() + image_bytes = self.get_thumbnail_bytes(obj) + if not image_bytes: + raise self.notfound() + # TODO: how to properly detect image type? + self.request.response.content_type = str('image/jpeg') + self.request.response.body = image_bytes + return self.request.response + + def get_thumbnail_bytes(self, obj): + raise NotImplementedError + def clone(self): """ View for cloning an object's data into a new object. """ self.viewing = True + self.cloning = True instance = self.get_instance() form = self.make_form(instance) self.configure_clone_form(form) if self.request.method == 'POST' and self.request.POST.get('clone') == 'clone': cloned = self.clone_instance(instance) - return self.redirect(self.get_action_url('view', cloned)) + self.request.session.flash("{} has been cloned: {}".format( + self.get_model_title(), self.get_instance_title(instance))) + self.request.session.flash("(NOTE, you are now viewing the clone!)") + return self.redirect_after_clone(cloned) return self.render_to_response('clone', { 'instance': instance, 'instance_title': self.get_instance_title(instance), @@ -408,7 +1281,43 @@ class MasterView(View): pass def clone_instance(self, instance): - raise NotImplementedError + """ + This method should create and return a *new* instance, which has been + "cloned" from the given instance. Default behavior assumes a typical + SQLAlchemy record instance, and the new one has all "column" values + copied *except* for the ``'uuid'`` column. + """ + cloned = self.model_class() + + for column in get_columns(instance): + if column.name != 'uuid': + setattr(cloned, column.name, getattr(instance, column.name)) + + self.Session.add(cloned) + self.Session.flush() + return cloned + + def redirect_after_clone(self, instance, **kwargs): + return self.redirect(self.get_action_url('view', instance)) + + def touch(self): + """ + View for "touching" an object so as to trigger datasync logic for it. + Useful instead of actually "editing" the object, which is generally the + alternative. + """ + obj = self.get_instance() + self.touch_instance(obj) + self.request.session.flash("{} has been touched: {}".format( + self.get_model_title(), self.get_instance_title(obj))) + return self.redirect(self.get_action_url('view', obj)) + + def touch_instance(self, obj): + """ + Perform actual "touch" logic for the given object. + """ + app = self.get_rattail_app() + app.touch_object(self.Session(), obj) def versions(self): """ @@ -420,9 +1329,8 @@ class MasterView(View): # return grid only, if partial page was requested if self.request.params.get('partial'): - self.request.response.content_type = b'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, @@ -441,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. """ @@ -449,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 = [] @@ -468,12 +1381,123 @@ 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() Transaction = continuum.transaction_class(model_class) transactions = model_transaction_query(self.Session(), instance, model_class, child_classes=self.normalize_version_child_classes()) @@ -491,22 +1515,57 @@ class MasterView(View): .first() instance_title = self.get_instance_title(instance) + + prev_url = next_url = None + if older: + prev_url = self.request.route_url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=older.id) + 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, 'previous_transaction': older, 'next_transaction': newer, 'title_for_version': self.title_for_version, 'fields_for_version': self.fields_for_version, 'continuum': continuum, + 'render_old_value': self.render_version_old_field_value, + 'render_new_value': self.render_version_new_field_value, }) 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() @@ -533,120 +1592,94 @@ class MasterView(View): versions.extend(query.all()) return versions - def mobile_view(self): - """ - Mobile view for displaying a single object's details - """ - self.viewing = True - instance = self.get_instance() - form = self.make_mobile_form(instance) + def render_version_old_field_value(self, version, field): + return repr(getattr(version.previous, field)) - context = { - 'instance': instance, - 'instance_title': self.get_instance_title(instance), - # 'instance_editable': self.editable_instance(instance), - # 'instance_deletable': self.deletable_instance(instance), - 'form': form, - } - if self.has_rows: - context['model_row_class'] = self.model_row_class - context['grid'] = self.make_mobile_row_grid(instance=instance) - return self.render_to_response('view', context, mobile=True) + def render_version_new_field_value(self, version, field, typ): + return repr(getattr(version, field)) - def make_mobile_form(self, instance, **kwargs): + def configure_common_form(self, form): """ - Make a FormAlchemy-based form for use with mobile CRUD views + Configure the form in whatever way is deemed "common" - i.e. where + configuration should be done the same for desktop and mobile. + + By default this removes the 'uuid' field (if present), sets any primary + key fields to be readonly (if we have a :attr:`model_class` and are in + edit mode), and sets labels as defined by the master class hierarchy. + + TODO: this logic should be moved back into configure_form() """ - fieldset = self.make_fieldset(instance) - self.preconfigure_mobile_fieldset(fieldset) - self.configure_mobile_fieldset(fieldset) - kwargs.setdefault('creating', self.creating) - kwargs.setdefault('editing', self.editing) - kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) - if self.creating: - kwargs.setdefault('cancel_url', self.get_index_url(mobile=True)) - else: - kwargs.setdefault('cancel_url', self.get_action_url('view', instance, mobile=True)) - factory = kwargs.pop('factory', forms.AlchemyForm) - kwargs.setdefault('session', self.Session()) - form = factory(self.request, fieldset, **kwargs) - form.readonly = self.viewing + form.remove_field('uuid') + + if self.editing: + model_class = self.get_model_class(error=False) + if model_class: + # set readonly for all primary key fields + mapper = orm.class_mapper(model_class) + for key in mapper.primary_key: + for field in form.fields: + if field == key.name: + form.set_readonly(field) + break + + self.set_labels(form) + + # hide "local only" field, unless global access allowed + if self.secure_global_objects: + if not self.has_perm('view_global'): + form.remove_field('local_only') + elif self.creating: + # assume this flag should be ON by default - it is hoped this + # is the safer option and would help prevent unwanted mistakes + form.set_default('local_only', True) + + def make_quick_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): + """ + Creates a "quick" form for adding a new row to the given instance. + """ + if factory is None: + factory = self.get_quick_row_form_factory() + if fields is None: + fields = self.get_quick_row_form_fields() + if schema is None: + schema = self.make_quick_row_form_schema() + + kwargs = self.make_quick_row_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_quick_row_form(form) return form - def preconfigure_mobile_row_fieldset(self, fieldset): - self._preconfigure_row_fieldset(fieldset) + def get_quick_row_form_factory(self, **kwargs): + return forms.Form - def configure_mobile_row_fieldset(self, fieldset): - self.configure_row_fieldset(fieldset) + def get_quick_row_form_fields(self, **kwargs): + pass - def make_mobile_row_form(self, row, **kwargs): - """ - Make a form for use with mobile CRUD views, for the given row object. - """ - fieldset = self.make_fieldset(row) - self.preconfigure_mobile_row_fieldset(fieldset) - self.configure_mobile_row_fieldset(fieldset) - kwargs.setdefault('session', self.Session()) - kwargs.setdefault('creating', self.creating) - kwargs.setdefault('editing', self.editing) - kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) - if 'cancel_url' not in kwargs: - kwargs['cancel_url'] = self.get_action_url('view', self.get_parent(row), mobile=True) - factory = kwargs.pop('factory', forms.AlchemyForm) - form = factory(self.request, fieldset, **kwargs) - form.readonly = self.viewing - return form + def make_quick_row_form_schema(self, **kwargs): + schema = colander.MappingSchema() + schema.add(colander.SchemaNode(colander.String(), name='quick_entry')) + return schema - def preconfigure_mobile_fieldset(self, fieldset): - self._preconfigure_fieldset(fieldset) - - def configure_mobile_fieldset(self, fieldset): - """ - Configure the given mobile fieldset. - """ - self.configure_fieldset(fieldset) - - def get_mobile_row_data(self, parent): - return self.get_row_data(parent) - - def mobile_row_listitem_field(self): - """ - Must return a FormAlchemy field to be appended to row grid, or ``None`` - if none is desired. - """ - return fa.Field('listitem', value=lambda obj: obj, - renderer=self.mobile_row_listitem_renderer()) - - def mobile_row_route_url(self, route_name, **kwargs): - route_name = 'mobile.{}.{}'.format(self.get_row_route_prefix(), route_name) - return self.request.route_url(route_name, **kwargs) - - def mobile_view_row(self): - """ - Mobile view for row items - """ - self.viewing = True - row = self.get_row_instance() - parent = self.get_parent(row) - form = self.make_mobile_row_form(row) - context = { - 'row': row, - 'parent_instance': parent, - 'parent_title': self.get_instance_title(parent), - 'parent_url': self.get_action_url('view', parent, mobile=True), - 'instance': row, - 'instance_title': self.get_row_instance_title(row), - 'instance_editable': self.row_editable(row), - 'parent_model_title': self.get_model_title(), - 'form': form, + def make_quick_row_form_kwargs(self, **kwargs): + defaults = { + 'request': self.request, + 'model_class': getattr(self, 'model_row_class', None), + 'cancel_url': self.request.get_referrer(), } - return self.render_to_response('view_row', context, mobile=True) - + defaults.update(kwargs) + return defaults + + def configure_quick_row_form(self, form, **kwargs): + pass + + def validate_quick_row_form(self, form): + return form.validate() + def make_default_row_grid_tools(self, obj): if self.rows_creatable: link = tags.link_to("Create a new {}".format(self.get_row_model_title()), self.get_action_url('create_row', obj)) - return HTML.tag('p', c=link) + return HTML.tag('p', c=[link]) def make_row_grid_tools(self, obj): return self.make_default_row_grid_tools(obj) @@ -674,18 +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() - - @classmethod - def get_row_route_prefix(cls): - """ - Route prefix specific to the row-level views for a batch, e.g. - ``'vendorcatalogs.rows'``. - """ - return "{}.rows".format(cls.get_route_prefix()) + return grid.get_visible_data() @classmethod def get_row_url_prefix(cls): @@ -711,18 +1736,13 @@ class MasterView(View): """ return True + def row_view_action_url(self, row, i): + return self.get_row_action_url('view', row) + def row_edit_action_url(self, row, i): if self.row_editable(row): return self.get_row_action_url('edit', row) - def row_deletable(self, row): - """ - Returns boolean indicating whether or not the given row can be - considered "deletable". Returns ``True`` by default; override as - necessary. - """ - return True - def row_delete_action_url(self, row, i): if self.row_deletable(row): return self.get_row_action_url('delete', row) @@ -738,6 +1758,8 @@ class MasterView(View): @classmethod def get_row_model_title_plural(cls): + if hasattr(cls, 'row_model_title_plural'): + return cls.row_model_title_plural return "{} Rows".format(cls.get_model_title()) def view_index(self): @@ -746,7 +1768,7 @@ class MasterView(View): """ try: index = int(self.request.GET['index']) - except KeyError, ValueError: + except (KeyError, ValueError): return self.redirect(self.get_index_url()) if index < 1: return self.redirect(self.get_index_url()) @@ -769,13 +1791,12 @@ class MasterView(View): obj = self.get_instance() filename = self.request.GET.get('filename', None) 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: - response.content_type = six.binary_type(content_type) - filename = os.path.basename(path).encode('ascii', 'replace') - response.content_disposition = b'attachment; filename={}'.format(filename) + response.content_type = content_type return response def download_content_type(self, path, filename): @@ -783,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. @@ -816,14 +1877,15 @@ class MasterView(View): context['dform'] = form.make_deform_form() return self.render_to_response('edit', context) - def validate_form(self, form): - return form.validate() - def save_edit_form(self, form): - self.save_form(form) - self.after_edit(form.fieldset.model) + uploads = self.normalize_uploads(form) + obj = self.objectify(form) + self.process_uploads(obj, form, uploads) + self.after_edit(obj) + self.Session.flush() + return obj - def redirect_after_edit(self, instance): + def redirect_after_edit(self, instance, **kwargs): return self.redirect(self.get_action_url('view', instance)) def delete(self): @@ -831,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() @@ -843,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': @@ -852,39 +1915,41 @@ 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): """ Delete all records matching the current grid query """ - if self.request.method == 'POST': - objects = self.get_effective_data() - key = '{}.bulk_delete'.format(self.model_class.__tablename__) - progress = SessionProgress(self.request, key) - thread = Thread(target=self.bulk_delete_thread, args=(objects, progress)) - thread.start() - return self.render_progress(progress, { - 'cancel_url': self.get_index_url(), - 'cancel_msg': "Bulk deletion was canceled", - }) - else: - self.request.session.flash("Sorry, you must POST to do a bulk delete operation") - return self.redirect(self.get_index_url()) + objects = self.get_effective_data() + key = '{}.bulk_delete'.format(self.model_class.__tablename__) + progress = self.make_progress(key) + thread = Thread(target=self.bulk_delete_thread, args=(objects, progress)) + thread.start() + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Bulk deletion was canceled", + }) def bulk_delete_objects(self, session, objects, progress=None): def delete(obj, i): - session.delete(obj) + if self.deletable_instance(obj): + self.delete_instance(obj) if i % 1000 == 0: session.flush() @@ -892,7 +1957,7 @@ class MasterView(View): message="Deleting objects") def get_bulk_delete_session(self): - return RattailSession() + return self.make_isolated_session() def bulk_delete_thread(self, objects, progress): """ @@ -911,7 +1976,8 @@ class MasterView(View): if progress: progress.session.load() progress.session['error'] = True - progress.session['error_msg'] = "Bulk deletion failed: {}: {}".format(type(error).__name__, error) + progress.session['error_msg'] = "Bulk deletion failed: {}".format( + simple_error(error)) progress.session.save() # If no error, check result flag (false means user canceled). @@ -924,42 +1990,177 @@ class MasterView(View): progress.session['success_url'] = self.get_index_url() progress.session.save() + def obtain_set(self): + """ + Obtain the effective "set" (selection) of records from POST data. + """ + # TODO: should have a cleaner way to parse object uuids? + uuids = self.request.POST.get('uuids') + if uuids: + uuids = uuids.split(',') + # TODO: probably need to allow override of fetcher callable + fetcher = lambda uuid: self.Session.get(self.model_class, uuid) + objects = [] + for uuid in uuids: + obj = fetcher(uuid) + if obj: + objects.append(obj) + return objects + + def enable_set(self): + """ + View which can turn ON the 'enabled' flag for a specific set of records. + """ + objects = self.obtain_set() + if objects: + enabled = 0 + for obj in objects: + if not obj.enabled: + obj.enabled = True + enabled += 1 + model_title_plural = self.get_model_title_plural() + self.request.session.flash("Enabled {} {}".format(enabled, model_title_plural)) + return self.redirect(self.get_index_url()) + + def disable_set(self): + """ + View which can turn OFF the 'enabled' flag for a specific set of records. + """ + objects = self.obtain_set() + if objects: + disabled = 0 + for obj in objects: + if obj.enabled: + obj.enabled = False + disabled += 1 + model_title_plural = self.get_model_title_plural() + self.request.session.flash("Disabled {} {}".format(disabled, model_title_plural)) + return self.redirect(self.get_index_url()) + + def delete_set(self): + """ + View which can delete a specific set of records. + """ + objects = self.obtain_set() + if objects: + for obj in objects: + self.delete_instance(obj) + model_title_plural = self.get_model_title_plural() + self.request.session.flash("Deleted {} {}".format(len(objects), model_title_plural)) + return self.redirect(self.get_index_url()) + + 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 host_object is None and local_object is None: + host_object = self.get_instance() + + 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) + + if local_object: + if importer.allow_update: + local_data = importer.normalize_local_object(local_object) + if importer.data_diffs(local_data, host_data) and importer.allow_update: + local_object = importer.update_object(local_object, host_data, local_data) + return local_object + 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 SessionProgress(self.request, 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: @@ -975,72 +2176,190 @@ 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): - return "Execution of {} failed: {}: {}".format(self.get_model_title(), - type(error).__name__, error) + return "Execution of {} failed: {}".format(self.get_model_title(), + simple_error(error)) 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): """ @@ -1048,14 +2367,24 @@ 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) for field in self.get_merge_coalesce_fields(): - if remove[field] and not keep[field]: + if remove[field] is not None and keep[field] is None: + result[field] = remove[field] + elif remove[field] and not keep[field]: result[field] = remove[field] for field in self.get_merge_additive_fields(): if isinstance(keep[field], (list, tuple)): @@ -1069,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 @@ -1104,14 +2439,44 @@ class MasterView(View): return cls.get_model_class().__name__.lower() @classmethod - def get_model_key(cls): + def get_model_key(cls, as_tuple=False): """ - Return a string name for the primary key of the model class. + 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'): - return cls.model_key - mapper = orm.class_mapper(cls.get_model_class()) - return ','.join([k.key for k in mapper.primary_key]) + keys = cls.model_key + if isinstance(keys, str): + keys = [keys] + else: + keys = get_primary_keys(cls.get_model_class()) + + if as_tuple: + return tuple(keys) + + return ','.join(keys) @classmethod def get_model_title(cls): @@ -1120,7 +2485,14 @@ class MasterView(View): """ if hasattr(cls, 'model_title'): return cls.model_title - return cls.get_model_class().get_model_title() + + # model class itself may provide title + model_class = cls.get_model_class() + if hasattr(model_class, 'get_model_title'): + return model_class.get_model_title() + + # otherwise just use model class name + return model_class.__name__ @classmethod def get_model_title_plural(cls): @@ -1142,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): @@ -1170,15 +2544,16 @@ class MasterView(View): """ return getattr(cls, 'permission_prefix', cls.get_route_prefix()) - def get_index_url(self, mobile=False, **kwargs): + def get_index_url(self, **kwargs): """ Returns the master view's index URL. """ - route = self.get_route_prefix() - if mobile: - route = 'mobile.{}'.format(route) - return self.request.route_url(route) + 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): """ @@ -1186,18 +2561,147 @@ class MasterView(View): """ return getattr(cls, 'index_title', cls.get_model_title_plural()) - def get_action_url(self, action, instance, mobile=False, **kwargs): + @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 """ kw = self.get_action_route_kwargs(instance) kw.update(kwargs) route_prefix = self.get_route_prefix() - if mobile: - route_prefix = 'mobile.{}'.format(route_prefix) return self.request.route_url('{}.{}'.format(route_prefix, action), **kw) - def render_to_response(self, template, data, mobile=False): + def get_help_url(self): + """ + May return a "help URL" if applicable. Default behavior is to simply + return the value of :attr:`help_url` (regardless of which view is in + effect), which in turn defaults to ``None``. If an actual URL is + returned, then a Help button will be shown in the page header; + otherwise it is not shown. + + This method is invoked whenever a template is rendered for a response, + 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. Note that ``template`` must only be a "key" (e.g. 'index' or 'view'). @@ -1212,16 +2716,33 @@ class MasterView(View): 'route_prefix': self.get_route_prefix(), 'permission_prefix': self.get_permission_prefix(), 'index_title': self.get_index_title(), - 'index_url': self.get_index_url(mobile=mobile), + '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, } + 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['row_route_prefix'] = self.get_row_route_prefix() + 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() @@ -1229,23 +2750,25 @@ 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)) - if hasattr(self, 'mobile_template_kwargs_{}'.format(template)): - context.update(getattr(self, 'mobile_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. - if mobile: - mako_path = '/mobile{}/{}.mako'.format(self.get_template_prefix(), template) - else: - mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template) + mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template) try: return render_to_response(mako_path, context, request=self.request) except IOError: # Failing that, try one or more fallback templates. - for fallback in self.get_fallback_templates(template, mobile=mobile): + for fallback in self.get_fallback_templates(template): try: return render_to_response(fallback, context, request=self.request) except IOError: @@ -1292,17 +2815,414 @@ class MasterView(View): return render('{}/{}.mako'.format(self.get_template_prefix(), template), context, request=self.request) - def get_fallback_templates(self, template, mobile=False): - if mobile: - return ['/mobile/master/{}.mako'.format(template)] + def get_fallback_templates(self, template, **kwargs): return ['/master/{}.mako'.format(template)] + def get_default_engine_dbkey(self): + """ + Returns the "default" engine dbkey. + """ + return self.rattail_config.get( + 'tailbone', + 'engines.{}.pretend_default'.format(self.engine_type_key), + default='default') + + def get_current_engine_dbkey(self): + """ + Returns the "current" engine's dbkey, for the current user. + """ + default = self.get_default_engine_dbkey() + return self.request.session.get('tailbone.engines.{}.current'.format(self.engine_type_key), + default) + def template_kwargs(self, **kwargs): """ Supplement the template context, for all views. """ + # whether or not to show the DB picker? + kwargs['expose_db_picker'] = False + if self.supports_multiple_engines: + + # DB picker is only shown for permissioned users + if self.request.has_perm('common.change_db_engine'): + + # 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 + supported database engines for the master view. Used with the DB + picker feature. + """ + engines = OrderedDict(self.rattail_config.trainwreck_engines) + hidden = self.rattail_config.getlist('tailbone', 'engines.rattail.hidden', + default=None) + if hidden: + for key in hidden: + engines.pop(key, None) + return engines + ############################## # Grid Stuff ############################## @@ -1319,9 +3239,17 @@ class MasterView(View): return cls.get_route_prefix() def get_row_grid_key(self): - return '{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()]) + model_key = self.get_model_key(as_tuple=True) + key = '.'.join([self.get_grid_key()] + + [self.request.matchdict[k] for k in model_key]) + 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, [] @@ -1358,12 +3286,17 @@ class MasterView(View): Return a list of 'main' actions for the grid. """ actions = [] - prefix = self.get_permission_prefix() - if self.viewable and self.request.has_perm('{}.view'.format(prefix)): - url = self.get_view_index_url if self.use_index_links else None - actions.append(self.make_action('view', icon='zoomin', url=url)) + if self.viewable and self.has_perm('view'): + actions.append(self.make_grid_action_view()) return actions + def make_grid_action_view(self): + 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()) return '{}?index={}'.format(self.request.route_url(route), self.first_visible_grid_index + i - 1) @@ -1373,43 +3306,127 @@ class MasterView(View): Return a list of 'more' actions for the grid. """ actions = [] - prefix = self.get_permission_prefix() - if self.editable and self.request.has_perm('{}.edit'.format(prefix)): - actions.append(self.make_action('edit', icon='pencil', url=self.default_edit_url)) - if self.deletable and self.request.has_perm('{}.delete'.format(prefix)): - actions.append(self.make_action('delete', icon='trash', url=self.default_delete_url)) + + # Edit + if self.editable and self.has_perm('edit'): + actions.append(self.make_grid_action_edit()) + + # Delete + if self.deletable and self.has_perm('delete'): + actions.append(self.make_grid_action_delete()) + return actions - def default_edit_url(self, row, i=None): - if self.editable_instance(row): + def make_grid_action_edit(self): + 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): + 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, 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()), + **self.get_action_route_kwargs(row)) def default_delete_url(self, row, i=None): if self.deletable_instance(row): return self.request.route_url('{}.delete'.format(self.get_route_prefix()), **self.get_action_route_kwargs(row)) - def make_action(self, key, url=None, **kwargs): + 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)) - return grids.GridAction(key, url=url, **kwargs) + if not factory: + factory = grids.GridAction + 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: - keys = [k.key for k in mapper.primary_key] - values = [getattr(row, k) for k in keys] + pkeys = get_primary_keys(obj) + keys = list(pkeys) + values = [getattr(obj, k) for k in keys] return dict(zip(keys, values)) def get_data(self, session=None): @@ -1434,11 +3451,23 @@ class MasterView(View): users. You would modify the base query to hide what you wanted, regardless of the user's filter selections. """ - return session.query(self.get_model_class()) + model_class = self.get_model_class() + query = session.query(model_class) + + # only show "local only" objects, unless global access allowed + if self.secure_global_objects: + 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 @@ -1455,43 +3484,862 @@ class MasterView(View): """ return False + def download_results_path(self, user_uuid, filename=None, + typ='results', makedirs=False): + """ + Returns an absolute path for the "results" data file, specific to the + given user UUID. + """ + route_prefix = self.get_route_prefix() + path = os.path.join(self.rattail_config.datadir(), 'downloads', + typ, route_prefix, + user_uuid[:2], user_uuid[2:]) + if makedirs and not os.path.exists(path): + os.makedirs(path) + + if filename: + path = os.path.join(path, filename) + return path + + def download_results_filename(self, fmt): + """ + Must return an appropriate "download results" filename for the given + format. E.g. ``'products.csv'`` + """ + route_prefix = self.get_route_prefix() + if fmt == 'csv': + return '{}.csv'.format(route_prefix) + if fmt == 'xlsx': + return '{}.xlsx'.format(route_prefix) + + def download_results_supported_formats(self): + # TODO: default formats should be configurable? + return OrderedDict([ + ('xlsx', "Excel (XLSX)"), + ('csv', "CSV"), + ]) + + def download_results_default_format(self): + # TODO: default format should be configurable + return 'xlsx' + + def download_results(self): + """ + View for saving current (filtered) data results into a file, and + downloading that file. + """ + route_prefix = self.get_route_prefix() + user_uuid = self.request.user.uuid + + # POST means generate a new results file for download + if self.request.method == 'POST': + + # make sure a valid format was requested + supported = self.download_results_supported_formats() + if not supported: + self.request.session.flash("There are no supported download formats!", + 'error') + return self.redirect(self.get_index_url()) + fmt = self.request.POST.get('fmt') + if not fmt: + fmt = self.download_results_default_format() or list(supported)[0] + if fmt not in supported: + self.request.session.flash("Unsupported download format: {}".format(fmt), + 'error') + return self.redirect(self.get_index_url()) + + # parse field list if one was given + fields = self.request.POST.get('fields') + if fields: + fields = fields.split(',') + + # start thread to actually do work / report progress + key = '{}.download_results'.format(route_prefix) + progress = self.make_progress(key) + results = self.get_effective_data() + thread = Thread(target=self.download_results_thread, + args=(results, fmt, fields, user_uuid, progress)) + thread.start() + + # show user the progress page + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Download was canceled.", + }) + + # not POST, so just download a file (if specified) + filename = self.request.GET.get('filename') + if not filename: + return self.redirect(self.get_index_url()) + path = self.download_results_path(user_uuid, filename) + return self.file_response(path) + + def download_results_thread(self, results, fmt, fields, user_uuid, progress): + """ + Thread target, which invokes :meth:`download_results_generate()` to + officially generate the data file which is then to be downloaded. + """ + route_prefix = self.get_route_prefix() + session = self.make_isolated_session() + try: + + # create folder(s) for output; make sure file doesn't exist + filename = self.download_results_filename(fmt) + path = self.download_results_path(user_uuid, filename, makedirs=True) + if os.path.exists(path): + os.remove(path) + + # generate file for download + results = results.with_session(session) + self.download_results_setup(fields, progress=progress) + self.download_results_generate(session, results, path, fmt, fields, + progress=progress) + + session.commit() + + except Exception as error: + msg = "failed to generate results file for download!" + log.warning(msg, exc_info=True) + session.rollback() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "{}: {}".format( + msg, simple_error(error)) + progress.session.save() + return + + finally: + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['extra_session_bits'] = { + '{}.results.generated'.format(route_prefix): path, + } + progress.session.save() + + def download_results_setup(self, fields, progress=None): + """ + Perform any up-front caching or other setup required, just prior to + generating a new results data file for download. + """ + + def download_results_generate(self, session, results, path, fmt, fields, progress=None): + """ + This method is responsible for actually generating the data file for a + "download results" operation, according to the given params. + """ + if fmt == 'csv': + + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) + writer.writeheader() + + def write(obj, i): + data = self.download_results_normalize(obj, fields, fmt=fmt) + csvrow = self.download_results_coerce_csv(data, fields) + writer.writerow(csvrow) + + self.progress_loop(write, results.all(), progress, + message="Writing data to CSV file") + csv_file.close() + + elif fmt == 'xlsx': + + writer = ExcelWriter(path, fields, + sheet_title=self.get_model_title_plural()) + writer.write_header() + + xlrows = [] + def write(obj, i): + data = self.download_results_normalize(obj, fields, fmt=fmt) + row = self.download_results_coerce_xlsx(data, fields) + xlrow = [row[field] for field in fields] + xlrows.append(xlrow) + + self.progress_loop(write, results.all(), progress, + message="Collecting data for Excel") + + def finalize(x, i): + writer.write_rows(xlrows) + writer.auto_freeze() + writer.auto_filter() + writer.auto_resize() + writer.save() + + self.progress_loop(finalize, [1], progress, + message="Writing Excel file to disk") + + def download_results_fields_available(self, **kwargs): + """ + Return the list of fields which are *available* to be written to + download file. Default field list will be constructed from the + underlying table columns. + """ + fields = [] + mapper = orm.class_mapper(self.model_class) + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + fields.append(prop.key) + return fields + + def download_results_fields_default(self, fields, **kwargs): + """ + Return the default list of fields to be written to download file. + Unless you override, all "available" fields will be included by + default. + """ + return fields + + def download_results_normalize(self, obj, fields, **kwargs): + """ + 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 = app.localtime(value, from_utc=not self.has_local_times) + + data[field] = value + + return data + + def download_results_coerce_csv(self, data, fields, **kwargs): + """ + Coerce the given data dict record, to a "row" dict suitable for use + when writing directly to CSV file. Each value in the dict should be a + string type. + """ + csvrow = dict(data) + for field in fields: + value = csvrow.get(field) + + if value is None: + value = '' + else: + value = str(value) + + csvrow[field] = value + + return csvrow + + def download_results_coerce_xlsx(self, data, fields, **kwargs): + """ + 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 = app.localtime(value, tzinfo=False) + + data[key] = value + + return data + def results_csv(self): """ - Download current list results as CSV + Download current list results as CSV. """ results = self.get_effective_data() - fields = self.get_csv_fields() - data = six.StringIO() - writer = UnicodeDictWriter(data, fields) - writer.writeheader() - for obj in results: - writer.writerow(self.get_csv_row(obj, fields)) + + # start thread to actually do work / generate progress data + route_prefix = self.get_route_prefix() + key = '{}.results_csv'.format(route_prefix) + progress = self.make_progress(key) + thread = Thread(target=self.results_csv_thread, + args=(results, self.request.user.uuid, progress)) + thread.start() + + # send user to progress page + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "CSV download was canceled.", + }) + + def results_csv_session(self): + return self.make_isolated_session() + + def results_csv_thread(self, results, user_uuid, progress): + """ + Thread target, responsible for actually generating the CSV file which + is to be presented for download. + """ + route_prefix = self.get_route_prefix() + session = self.results_csv_session() + try: + + # create folder(s) for output; make sure file doesn't exist + path = os.path.join(self.rattail_config.datadir(), 'downloads', + 'results-csv', route_prefix, + user_uuid[:2], user_uuid[2:]) + if not os.path.exists(path): + os.makedirs(path) + path = os.path.join(path, '{}.csv'.format(route_prefix)) + if os.path.exists(path): + os.remove(path) + + results = results.with_session(session).all() + fields = self.get_csv_fields() + + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) + writer.writeheader() + + def write(obj, i): + writer.writerow(self.get_csv_row(obj, fields)) + + self.progress_loop(write, results, progress, + message="Collecting data for CSV") + csv_file.close() + session.commit() + + except Exception as error: + msg = "generating CSV file for download failed!" + log.warning(msg, exc_info=True) + session.rollback() + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "{}: {}".format( + msg, simple_error(error)) + progress.session.save() + return + + finally: + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['extra_session_bits'] = { + '{}.results_csv.generated'.format(route_prefix): True, + } + progress.session.save() + + def results_csv_download(self): + route_prefix = self.get_route_prefix() + user_uuid = self.request.user.uuid + path = os.path.join(self.rattail_config.datadir(), 'downloads', + 'results-csv', route_prefix, + user_uuid[:2], user_uuid[2:], + '{}.csv'.format(route_prefix)) + return self.file_response(path) + + def results_xlsx(self): + """ + Download current list results as XLSX. + """ + results = self.get_effective_data() + + # start thread to actually do work / generate progress data + route_prefix = self.get_route_prefix() + key = '{}.results_xlsx'.format(route_prefix) + progress = self.make_progress(key) + thread = Thread(target=self.results_xlsx_thread, + args=(results, self.request.user.uuid, progress)) + thread.start() + + # send user to progress page + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "XLSX download was canceled.", + }) + + 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 + is to be presented for download. + """ + route_prefix = self.get_route_prefix() + session = self.results_xlsx_session() + try: + + # create folder(s) for output; make sure file doesn't exist + path = os.path.join(self.rattail_config.datadir(), 'downloads', + 'results-xlsx', route_prefix, + user_uuid[:2], user_uuid[2:]) + if not os.path.exists(path): + os.makedirs(path) + path = os.path.join(path, '{}.xlsx'.format(route_prefix)) + if os.path.exists(path): + os.remove(path) + + results = results.with_session(session).all() + fields = self.get_xlsx_fields() + + # write output file + self.results_write_xlsx(path, fields, results, session, progress=progress) + + except Exception as error: + msg = "generating XLSX file for download failed!" + log.warning(msg, exc_info=True) + session.rollback() + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "{}: {}".format( + msg, simple_error(error)) + progress.session.save() + return + + session.commit() + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['extra_session_bits'] = { + '{}.results_xlsx.generated'.format(route_prefix): True, + } + progress.session.save() + + def results_xlsx_download(self): + route_prefix = self.get_route_prefix() + user_uuid = self.request.user.uuid + path = os.path.join(self.rattail_config.datadir(), 'downloads', + 'results-xlsx', route_prefix, + user_uuid[:2], user_uuid[2:], + '{}.xlsx'.format(route_prefix)) + return self.file_response(path) + + def get_xlsx_fields(self): + """ + Return the list of fields to be written to XLSX download. + """ + fields = [] + mapper = orm.class_mapper(self.model_class) + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + fields.append(prop.key) + return fields + + def get_xlsx_row(self, obj, fields): + """ + Return a dict for use when writing the row's data to CSV download. + """ + row = {} + for field in fields: + row[field] = getattr(obj, field, None) + return row + + def download_results_rows_supported_formats(self): + # TODO: default formats should be configurable? + return OrderedDict([ + ('xlsx', "Excel (XLSX)"), + ('csv', "CSV"), + ]) + + def download_results_rows_default_format(self): + # TODO: default format should be configurable + return 'xlsx' + + def download_results_rows(self): + """ + View for saving *rows* of current (filtered) data results into a file, + and downloading that file. + """ + route_prefix = self.get_route_prefix() + user_uuid = self.request.user.uuid + + # POST means generate a new results file for download + if self.request.method == 'POST': + + # make sure a valid format was requested + supported = self.download_results_rows_supported_formats() + if not supported: + self.request.session.flash("There are no supported download formats!", + 'error') + return self.redirect(self.get_index_url()) + fmt = self.request.POST.get('fmt') + if not fmt: + fmt = self.download_results_rows_default_format() or list(supported)[0] + if fmt not in supported: + self.request.session.flash("Unsupported download format: {}".format(fmt), + 'error') + return self.redirect(self.get_index_url()) + + # parse field list if one was given + fields = self.request.POST.get('fields') + if fields: + fields = fields.split(',') + if not fields: + if fmt == 'csv': + fields = self.get_row_csv_fields() + elif fmt == 'xlsx': + fields = self.get_row_xlsx_fields() + else: + self.request.session.flash("No fields were specified", 'error') + return self.redirect(self.get_index_url()) + + # start thread to actually do work / report progress + key = '{}.download_results_rows'.format(route_prefix) + progress = self.make_progress(key) + results = self.get_effective_data() + thread = Thread(target=self.download_results_rows_thread, + args=(results, fmt, fields, user_uuid, progress)) + thread.start() + + # show user the progress page + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Download was canceled.", + }) + + # not POST, so just download a file (if specified) + filename = self.request.GET.get('filename') + if not filename: + return self.redirect(self.get_index_url()) + path = self.download_results_rows_path(user_uuid, filename) + return self.file_response(path) + + def download_results_rows_filename(self, fmt): + """ + Must return an appropriate "download results" filename for the given + format. E.g. ``'products.csv'`` + """ + route_prefix = self.get_route_prefix() + if fmt == 'csv': + return '{}.rows.csv'.format(route_prefix) + if fmt == 'xlsx': + return '{}.rows.xlsx'.format(route_prefix) + + def download_results_rows_path(self, user_uuid, filename=None, + typ='results', makedirs=False): + """ + Returns an absolute path for the "results" data file, specific to the + given user UUID. + """ + route_prefix = self.get_route_prefix() + path = os.path.join(self.rattail_config.datadir(), 'downloads', + typ, route_prefix, + user_uuid[:2], user_uuid[2:]) + if makedirs and not os.path.exists(path): + os.makedirs(path) + + if filename: + path = os.path.join(path, filename) + return path + + def download_results_rows_fields_available(self, **kwargs): + """ + Return the list of fields which are *available* to be written to + download file. Default field list will be constructed from the + underlying table columns. + """ + fields = [] + mapper = orm.class_mapper(self.model_class) + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + fields.append(prop.key) + return fields + + def download_results_rows_fields_default(self, fields, **kwargs): + """ + Return the default list of fields to be written to download file. + Unless you override, all "available" fields will be included by + default. + """ + return fields + + def download_results_rows_thread(self, results, fmt, fields, user_uuid, progress): + """ + Thread target, which invokes :meth:`download_results_generate()` to + officially generate the data file which is then to be downloaded. + """ + route_prefix = self.get_route_prefix() + session = self.make_isolated_session() + try: + + # create folder(s) for output; make sure file doesn't exist + filename = self.download_results_rows_filename(fmt) + path = self.download_results_rows_path(user_uuid, filename, makedirs=True) + if os.path.exists(path): + os.remove(path) + + # generate file for download + results = results.with_session(session).all() + self.download_results_rows_setup(fields, progress=progress) + self.download_results_rows_generate(session, results, path, fmt, fields, + progress=progress) + + session.commit() + + except Exception as error: + msg = "failed to generate results file for download!" + log.warning(msg, exc_info=True) + session.rollback() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "{}: {}".format( + msg, simple_error(error)) + progress.session.save() + return + + finally: + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['extra_session_bits'] = { + '{}.results_rows.generated'.format(route_prefix): path, + } + progress.session.save() + + def download_results_rows_setup(self, fields, progress=None): + """ + Perform any up-front caching or other setup required, just prior to + generating a new results data file for download. + """ + + def download_results_rows_generate(self, session, results, path, fmt, fields, progress=None): + """ + This method is responsible for actually generating the data file for a + "download rows for results" operation, according to the given params. + """ + # we really are concerned with "rows of results" here, so let's just + # replace the 'results' list with a list of rows + original_results = results + results = [] + + def collect(obj, i): + results.extend(self.get_row_data(obj).all()) + + self.progress_loop(collect, original_results, progress, + message="Collecting data for {}".format(self.get_row_model_title_plural())) + + if fmt == 'csv': + + csv_file = open(path, 'wt', encoding='utf_8') + writer = csv.DictWriter(csv_file, fields) + writer.writeheader() + + def write(obj, i): + data = self.download_results_rows_normalize(obj, fields, fmt=fmt) + csvrow = self.download_results_rows_coerce_csv(data, fields) + writer.writerow(csvrow) + + self.progress_loop(write, results, progress, + message="Writing data to CSV file") + csv_file.close() + + elif fmt == 'xlsx': + + writer = ExcelWriter(path, fields, + sheet_title=self.get_row_model_title_plural()) + writer.write_header() + + xlrows = [] + def write(obj, i): + data = self.download_results_rows_normalize(obj, fields, fmt=fmt) + row = self.download_results_rows_coerce_xlsx(data, fields) + xlrow = [row[field] for field in fields] + xlrows.append(xlrow) + + self.progress_loop(write, results, progress, + message="Collecting data for Excel") + + def finalize(x, i): + writer.write_rows(xlrows) + writer.auto_freeze() + writer.auto_filter() + writer.auto_resize() + writer.save() + + self.progress_loop(finalize, [1], progress, + message="Writing Excel file to disk") + + def download_results_rows_normalize(self, row, fields, **kwargs): + """ + 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 = app.localtime(value, from_utc=not self.has_local_times) + + data[field] = value + + return data + + def download_results_rows_coerce_csv(self, data, fields, **kwargs): + """ + Coerce the given data dict record, to a "row" dict suitable for use + when writing directly to CSV file. Each value in the dict should be a + string type. + """ + csvrow = dict(data) + for field in fields: + value = csvrow.get(field) + + if value is None: + value = '' + else: + value = str(value) + + csvrow[field] = value + + return csvrow + + def download_results_rows_coerce_xlsx(self, data, fields, **kwargs): + """ + 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] + + # convert GPC to pretty string + if isinstance(value, GPC): + value = value.pretty() + + # make timestamps local, "zone-naive" + elif isinstance(value, datetime.datetime): + value = app.localtime(value, tzinfo=False) + + data[key] = value + + return data + + def row_results_xlsx(self): + """ + 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 = app.make_temp_file(suffix='.xlsx') + writer = ExcelWriter(path, fields, sheet_title=self.get_row_model_title_plural()) + writer.write_header() + + rows = [] + for row_obj in results: + data = self.get_row_xlsx_row(row_obj, fields) + row = [data[field] for field in fields] + rows.append(row) + + writer.write_rows(rows) + writer.auto_freeze() + writer.auto_filter() + writer.auto_resize() + writer.save() + response = self.request.response - response.body = data.getvalue() - data.close() + with open(path, 'rb') as f: + response.body = f.read() + os.remove(path) + response.content_length = len(response.body) - response.content_type = b'text/csv' - response.content_disposition = b'attachment; filename={}.csv'.format(self.get_grid_key()) + response.content_type = str('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + filename = self.get_row_results_xlsx_filename(obj) + response.content_disposition = str('attachment; filename={}'.format(filename)) return response + def get_row_xlsx_fields(self): + """ + Return the list of row fields to be written to XLSX download. + """ + # TODO: should this be shared at all? in a better way? + return self.get_row_csv_fields() + + def get_row_xlsx_row(self, row, fields): + """ + 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 = 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 = app.localtime(value, tzinfo=False) + else: + value = app.localtime(value, from_utc=True, tzinfo=False) + + xlrow[field] = value + return xlrow + + def get_row_results_xlsx_filename(self, obj): + return '{}.xlsx'.format(self.get_row_grid_key()) + def row_results_csv(self): """ Download current row results data for an object, as CSV """ 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 - response.body = data.getvalue() + filename = self.get_row_results_csv_filename(obj) + response.text = data.getvalue() + response.content_type = 'text/csv' + response.content_disposition = 'attachment; filename={}'.format(filename) data.close() response.content_length = len(response.body) - response.content_type = b'text/csv' - filename = self.get_row_results_csv_filename(obj) - response.content_disposition = b'attachment; filename={}'.format(filename) return response def get_row_results_csv_filename(self, instance): @@ -1499,7 +4347,8 @@ class MasterView(View): def get_csv_fields(self): """ - Return the list of fields to be written to CSV download. + Return the list of fields to be written to CSV download. Default field + list will be constructed from the underlying table columns. """ fields = [] mapper = orm.class_mapper(self.model_class) @@ -1512,31 +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) - csvrow[field] = '' if value is None else six.text_type(value) + if isinstance(value, datetime.datetime): + # TODO: this assumes value is *always* naive UTC + 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) - csvrow[field] = '' if value is None else six.text_type(value) + if isinstance(value, datetime.datetime): + # TODO: this assumes value is *always* naive UTC + value = app.localtime(value, from_utc=True) + csvrow[field] = '' if value is None else str(value) return csvrow ############################## @@ -1548,70 +4411,289 @@ class MasterView(View): Fetch the current model instance by inspecting the route kwargs and doing a database lookup. If the instance cannot be found, raises 404. """ - # TODO: this can't handle composite model key..is that needed? - key = self.request.matchdict[self.get_model_key()] - instance = self.Session.query(self.get_model_class()).get(key) - if not instance: - raise httpexceptions.HTTPNotFound() - return instance + model_keys = self.get_model_key(as_tuple=True) + query = self.Session.query(self.get_model_class()) + + def filtr(query, model_key): + key = self.request.matchdict[model_key] + if self.key_is_integer(model_key): + key = int(key) + query = query.filter(getattr(self.model_class, model_key) == key) + return query + + # filter query by composite key. we use filter() instead of a simple + # get() here in case view uses a "pseudo-PK" + for i, model_key in enumerate(model_keys): + query = filtr(query, model_key) + try: + obj = query.one() + except orm.exc.NoResultFound: + raise self.notfound() + + # pretend global object doesn't exist, unless access allowed + if self.secure_global_objects: + if not obj.local_only: + if not self.has_perm('view_global'): + raise self.notfound() + + return obj + + def key_is_integer(self, model_key): + + # inspect model class to determine if model_key is numeric + cls = self.get_model_class(error=False) + if cls: + attr = getattr(cls, model_key) + if isinstance(attr.type, sa.Integer): + return True + + # do not assume integer by default + return False def get_instance_title(self, instance): """ Return a "pretty" title for the instance, to be used in the page title etc. """ - return six.text_type(instance) + return str(instance) - def make_form(self, instance, **kwargs): + @classmethod + def get_form_factory(cls): """ - Make a FormAlchemy-based form for use with CRUD views. + Returns the grid factory or class which is to be used when creating new + grid instances. """ - # TODO: Some hacky stuff here, to accommodate old form cruft. Probably - # should refactor forms soon too, but trying to avoid it for the moment. + return getattr(cls, 'form_factory', forms.Form) - kwargs.setdefault('creating', self.creating) - kwargs.setdefault('editing', self.editing) + @classmethod + def get_row_form_factory(cls): + """ + Returns the factory or class which is to be used when creating new row + forms. + """ + return getattr(cls, 'row_form_factory', forms.Form) - fieldset = self.make_fieldset(instance) - self._preconfigure_fieldset(fieldset) - self.configure_fieldset(fieldset) - self._postconfigure_fieldset(fieldset) + 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 render_downloadable_file(self, obj, field): + if hasattr(obj, field): + filename = getattr(obj, field) + else: + filename = obj[field] + if not filename: + return "" + path = self.download_path(obj, filename) + url = self.get_action_url('download', obj, _query={'filename': filename}) + return self.render_file_field(path, url) + + def render_file_field(self, path, url=None, filename=None): + """ + Convenience for rendering a file with optional download link + """ + if not filename: + filename = os.path.basename(path) + content = "{} ({})".format(filename, self.readable_size(path)) + if url: + return tags.link_to(content, url) + return content + + def readable_size(self, path, size=None): + # TODO: this was shamelessly copied from FormAlchemy ... + if size is None: + size = self.get_size(path) + if size == 0: + return '0 KB' + if size <= 1024: + return '1 KB' + if size > 1048576: + return '%0.02f MB' % (size / 1048576.0) + return '%0.02f KB' % (size / 1024.0) + + def get_size(self, path): + try: + return os.path.getsize(path) + except os.error: + return 0 + + def make_form(self, instance=None, factory=None, fields=None, schema=None, make_kwargs=None, configure=None, **kwargs): + """ + Creates a new form for the given model class/instance + """ + if factory is None: + factory = self.get_form_factory() + if fields is None: + fields = self.get_form_fields() + if schema is None: + schema = self.make_form_schema() + if make_kwargs is None: + make_kwargs = self.make_form_kwargs + if configure is None: + configure = self.configure_form + + # TODO: SQLAlchemy class instance is assumed *unless* we get a dict + # (seems like we should be smarter about this somehow) + # if not self.creating and not isinstance(instance, dict): + if not self.creating: + kwargs['model_instance'] = instance + kwargs = make_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + configure(form) + return form + + def get_form_fields(self): + if hasattr(self, 'form_fields'): + return self.form_fields + # TODO + # raise NotImplementedError + + def make_form_schema(self): + if not self.model_class: + # TODO + raise NotImplementedError + + def make_form_kwargs(self, **kwargs): + """ + 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.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)) - kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) 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)) - factory = kwargs.pop('factory', forms.AlchemyForm) - kwargs.setdefault('session', self.Session()) - form = factory(self.request, fieldset, **kwargs) - form.readonly = self.viewing - return form + + 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(): + self.form_deserialized = form.validated + return True + return False + + def objectify(self, form, data=None): + """ + Create and/or update the model instance from the given form, and return + this object. + + .. todo:: + This needs a better explanation. And probably tests. + """ + if data is None: + data = form.validated + + obj = form.schema.objectify(data, context=form.model_instance) + + if self.is_contact: + obj = self.objectify_contact(obj, data) + + # force "local only" flag unless global access granted + if self.secure_global_objects: + 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'] + if contact.emails: + if address: + email = contact.emails[0] + email.address = address + else: + contact.emails.pop(0) + elif address: + contact.add_email_address(address) + + if 'default_phone' in data: + number = app.format_phone_number(data['default_phone']) + if contact.phones: + if number: + phone = contact.phones[0] + phone.number = number + else: + contact.phones.pop(0) + elif number: + contact.add_phone_number(number) + + address_fields = ('address_street', + 'address_street2', + 'address_city', + 'address_state', + 'address_zipcode') + + addr = dict([(field, data[field]) + for field in address_fields + if field in data]) + + if any(addr.values()): + # we strip 'address_' prefix from fields + addr = dict([(field[8:], value) + for field, value in addr.items()]) + if contact.addresses: + address = contact.addresses[0] + for field, value in addr.items(): + setattr(address, field, value) + else: + contact.add_mailing_address(**addr) + + elif any([field in data for field in address_fields]) and contact.addresses: + contact.addresses.pop() + + return contact def save_form(self, form): form.save() - def make_fieldset(self, instance, **kwargs): - """ - Make a FormAlchemy fieldset for the given model instance. - """ - kwargs.setdefault('session', self.Session()) - kwargs.setdefault('request', self.request) - fieldset = fa.FieldSet(instance, **kwargs) - fieldset.prettify = prettify - return fieldset - - def _preconfigure_fieldset(self, fieldset): - pass - - def configure_fieldset(self, fieldset): - """ - Configure the given fieldset. - """ - fieldset.configure() - - def _postconfigure_fieldset(self, fieldset): - pass - def before_create(self, form): """ Event hook, called just after the form to create a new instance has @@ -1623,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 @@ -1653,10 +4740,14 @@ class MasterView(View): """ Delete the instance, or mark it as deleted, or whatever you need to do. """ + # note, we don't use self.Session here, in case we're being called from + # a separate (bulk-delete) thread + session = orm.object_session(instance) + session.delete(instance) + # Flush immediately to force any pending integrity errors etc.; that # way we don't set flash message until we know we have success. - self.Session.delete(instance) - self.Session.flush() + session.flush() def get_after_delete_url(self, instance): """ @@ -1669,6 +4760,35 @@ class MasterView(View): return self.after_delete_url return self.get_index_url() + ############################## + # Autocomplete Stuff + ############################## + + def autocomplete(self): + """ + View which accepts a single ``term`` param, and returns a list + of autocomplete results to match. + """ + app = self.get_rattail_app() + key = self.get_autocompleter_key() + # url may include key, for more specific autocompleter + if 'key' in self.request.matchdict: + key = '{}.{}'.format(key, self.request.matchdict['key']) + autocompleter = app.get_autocompleter(key) + + term = self.request.params.get('term', '') + return autocompleter.autocomplete(self.Session(), term) + + def get_autocompleter_key(self): + """ + Must return the "key" to be used when locating the + Autocompleter object, for use with autocomplete view. + """ + if hasattr(self, 'autocompleter_key'): + if self.autocompleter_key: + return self.autocompleter_key + return self.get_route_prefix() + ############################## # Associated Rows Stuff ############################## @@ -1682,10 +4802,9 @@ class MasterView(View): index_url = self.get_action_url('view', parent) form = self.make_row_form(self.model_row_class, cancel_url=index_url) if self.request.method == 'POST': - if form.validate(): + if self.validate_row_form(form): self.before_create_row(form) - self.save_create_row_form(form) - obj = form.fieldset.model + obj = self.save_create_row_form(form) self.after_create_row(obj) return self.redirect_after_create_row(obj) return self.render_to_response('create_row', { @@ -1695,8 +4814,19 @@ class MasterView(View): self.get_instance_title(parent)), 'form': form}) + # TODO: still need to verify this logic def save_create_row_form(self, form): - self.save_row_form(form) + # self.before_create(form) + # with self.Session().no_autoflush: + # obj = self.objectify(form, self.form_deserialized) + # self.before_create_flush(obj, form) + obj = self.objectify(form, self.form_deserialized) + self.Session.add(obj) + self.Session.flush() + return obj + + # def save_create_row_form(self, form): + # self.save_row_form(form) def before_create_row(self, form): pass @@ -1704,30 +4834,17 @@ class MasterView(View): def after_create_row(self, row_object): pass - def redirect_after_create_row(self, row, mobile=False): - return self.redirect(self.get_row_action_url('view', row, mobile=mobile)) + def redirect_after_create_row(self, row, **kwargs): + return self.redirect(self.get_row_action_url('view', row)) - def mobile_create_row(self): - """ - Mobile view for creating a new row object - """ - self.creating = True - parent = self.get_instance() - instance_url = self.get_action_url('view', parent, mobile=True) - form = self.make_mobile_row_form(self.model_row_class, cancel_url=instance_url) - if self.request.method == 'POST': - if form.validate(): - self.before_create_row(form) - # let save() return alternate object if necessary - obj = self.save_create_row_form(form) or form.fieldset.model - self.after_create_row(obj) - return self.redirect_after_create_row(obj, mobile=True) - return self.render_to_response('create_row', { - 'instance_title': self.get_instance_title(parent), - 'instance_url': instance_url, - 'parent_object': parent, - 'form': form, - }, mobile=True) + def save_quick_row_form(self, form): + raise NotImplementedError("You must define `{}:{}.save_quick_row_form()` " + "in order to process quick row forms".format( + self.__class__.__module__, + self.__class__.__name__)) + + def redirect_after_quick_row(self, row, **kwargs): + return self.redirect(self.get_row_action_url('edit', row)) def view_row(self): """ @@ -1740,12 +4857,14 @@ class MasterView(View): return self.render_to_response('view_row', { 'instance': row, 'instance_title': self.get_instance_title(parent), + 'row_title': self.get_row_instance_title(row), 'instance_url': self.get_action_url('view', parent), 'instance_editable': self.row_editable(row), 'instance_deletable': self.row_deletable(row), 'rows_creatable': self.rows_creatable and self.rows_creatable_for(parent), 'model_title': self.get_row_model_title(), 'model_title_plural': self.get_row_model_title_plural(), + 'parent_instance': parent, 'parent_model_title': self.get_model_title(), 'action_url': self.get_row_action_url, 'form': form}) @@ -1757,6 +4876,13 @@ class MasterView(View): """ return True + def rows_quickable_for(self, instance): + """ + Must return boolean indicating whether the "quick row" feature should + be allowed for the given instance. Returns ``True`` by default. + """ + return True + def row_editable(self, row): """ Returns boolean indicating whether or not the given row can be @@ -1771,10 +4897,13 @@ class MasterView(View): """ self.editing = True row = self.get_row_instance() + if not self.row_editable(row): + raise self.redirect(self.get_row_action_url('view', row)) + form = self.make_row_form(row) if self.request.method == 'POST': - if form.validate(): + if self.validate_row_form(form): self.save_edit_row_form(form) return self.redirect_after_edit_row(row) @@ -1782,53 +4911,32 @@ 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, 'instance_title': self.get_row_instance_title(row), 'instance_deletable': self.row_deletable(row), - 'form': form}) - - def mobile_edit_row(self): - """ - Mobile view for editing a row object - """ - self.editing = True - row = self.get_row_instance() - instance_url = self.get_row_action_url('view', row, mobile=True) - form = self.make_mobile_row_form(row) - - if self.request.method == 'POST': - if form.validate(): - self.save_edit_row_form(form) - return self.redirect_after_edit_row(row, mobile=True) - - parent = self.get_parent(row) - return self.render_to_response('edit_row', { - 'instance': row, - 'instance_title': self.get_row_instance_title(row), - 'instance_url': instance_url, - 'instance_deletable': self.row_deletable(row), - 'parent_instance': parent, - 'parent_title': self.get_instance_title(parent), - 'parent_url': self.get_action_url('view', parent, mobile=True), - 'form': form}, - mobile=True) + 'form': form, + 'dform': form.make_deform_form(), + }) def save_edit_row_form(self, form): - self.save_row_form(form) - self.after_edit_row(form.fieldset.model) + obj = self.objectify(form, self.form_deserialized) + self.after_edit_row(obj) + self.Session.flush() + return obj - def save_row_form(self, form): - form.save() + # def save_row_form(self, form): + # form.save() def after_edit_row(self, row): """ Event hook, called just after an existing row object is saved. """ - def redirect_after_edit_row(self, row, mobile=False): - return self.redirect(self.get_row_action_url('view', row, mobile=mobile)) + def redirect_after_edit_row(self, row, **kwargs): + return self.redirect(self.get_row_action_url('view', row)) def row_deletable(self, row): """ @@ -1836,17 +4944,50 @@ 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): + """ + Perform the actual deletion of given row object. + """ + self.Session.delete(row) + def delete_row(self): """ - "Delete" a sub-row from the parent. + Desktop view which can "delete" a sub-row from the parent. """ - row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid']) - if not row: - raise httpexceptions.HTTPNotFound() - self.Session.delete(row) - return self.redirect(self.get_action_url('edit', self.get_parent(row))) + row = self.get_row_instance() + if not self.row_deletable(row): + raise self.redirect(self.get_row_action_url('view', row)) + + 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 @@ -1855,51 +4996,169 @@ class MasterView(View): return self.get_row_model_title() def get_row_instance(self): - key = self.request.matchdict[self.get_model_key()] - instance = self.Session.query(self.model_row_class).get(key) + # TODO: is this right..? + # key = self.request.matchdict[self.get_model_key()] + key = self.request.matchdict['row_uuid'] + instance = self.Session.get(self.model_row_class, key) if not instance: - raise httpexceptions.HTTPNotFound() + raise self.notfound() return instance - def make_row_form(self, instance, **kwargs): + def make_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): """ - Make a FormAlchemy form for use with CRUD views for a data *row*. + Creates a new row form for the given model class/instance. """ - # TODO: Some hacky stuff here, to accommodate old form cruft. Probably - # should refactor forms soon too, but trying to avoid it for the moment. + if factory is None: + factory = self.get_row_form_factory() + if fields is None: + fields = self.get_row_form_fields() + if schema is None: + schema = self.make_row_form_schema() - kwargs.setdefault('creating', self.creating) - kwargs.setdefault('editing', self.editing) - - fieldset = self.make_fieldset(instance) - self._preconfigure_row_fieldset(fieldset) - self.configure_row_fieldset(fieldset) - - kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) - if 'cancel_url' not in kwargs: - if self.creating: - kwargs['cancel_url'] = self.get_action_url('view', self.get_parent(instance)) - else: - kwargs['cancel_url'] = self.get_row_action_url('view', instance) - - kwargs.setdefault('session', self.Session()) - form = forms.AlchemyForm(self.request, fieldset, **kwargs) - form.readonly = self.viewing + if not self.creating: + kwargs['model_instance'] = instance + kwargs = self.make_row_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_row_form(form) return form - def _preconfigure_row_fieldset(self, fs): - pass + def get_row_form_fields(self): + if hasattr(self, 'row_form_fields'): + return self.row_form_fields + # TODO + # raise NotImplementedError - def configure_row_fieldset(self, fs): - fs.configure() + def make_row_form_schema(self): + if not self.model_row_class: + # TODO + raise NotImplementedError - def get_row_action_url(self, action, row, mobile=False): + def make_row_form_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new row forms. + """ + defaults = { + 'request': self.request, + 'readonly': self.viewing, + 'model_class': getattr(self, 'model_row_class', None), + 'action_url': self.request.current_route_url(_query=None), + } + if self.creating: + kwargs.setdefault('cancel_url', self.request.get_referrer()) + else: + instance = kwargs['model_instance'] + if 'cancel_url' not in kwargs: + kwargs['cancel_url'] = self.get_row_action_url('view', instance) + defaults.update(kwargs) + return defaults + + def configure_row_form(self, form): + """ + Configure a row form. + """ + # TODO: is any of this stuff from configure_form() needed? + # if self.editing: + # model_class = self.get_model_class(error=False) + # if model_class: + # mapper = orm.class_mapper(model_class) + # for key in mapper.primary_key: + # for field in form.fields: + # if field == key.name: + # form.set_readonly(field) + # break + # form.remove_field('uuid') + + 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(): + 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. """ - route_name = '{}.{}'.format(self.get_row_route_prefix(), action) - if mobile: - route_name = 'mobile.{}'.format(route_name) + route_name = '{}.{}_row'.format(self.get_route_prefix(), action) return self.request.route_url(route_name, **self.get_row_action_route_kwargs(row)) def get_row_action_route_kwargs(self, row): @@ -1907,13 +5166,425 @@ class MasterView(View): Hopefully generic kwarg generator for basic action routes. """ # TODO: make this smarter? - return {'uuid': row.uuid} + parent = self.get_parent(row) + return { + 'uuid': parent.uuid, + 'row_uuid': row.uuid, + } 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 @@ -1923,6 +5594,26 @@ class MasterView(View): """ cls._defaults(config) + @classmethod + def get_instance_url_prefix(cls): + """ + Generate the URL prefix specific to an instance for this model view. + Winds up looking something like: + + * ``/products/{uuid}`` + * ``/params/{foo}|{bar}|{baz}`` + """ + url_prefix = cls.get_url_prefix() + model_keys = cls.get_model_key(as_tuple=True) + + prefix = '{}/'.format(url_prefix) + for i, key in enumerate(model_keys): + if i: + prefix += '|' + prefix += '{{{}}}'.format(key) + + return prefix + @classmethod def _defaults(cls, config): """ @@ -1931,49 +5622,158 @@ class MasterView(View): 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() permission_prefix = cls.get_permission_prefix() 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_route_prefix = cls.get_row_route_prefix() - row_url_prefix = cls.get_row_url_prefix() 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)) - if cls.supports_mobile: - config.add_route('mobile.{}'.format(route_prefix), '/mobile{}/'.format(url_prefix)) - config.add_view(cls, attr='mobile_index', route_name='mobile.{}'.format(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 + # enable it if the class declares it, *and* does *not* declare the + # older style(s). that way each class must explicitly choose + # *only* the new style in order to use it + if cls.results_downloadable and not ( + cls.results_downloadable_csv or cls.results_downloadable_xlsx): + config.add_tailbone_permission(permission_prefix, '{}.download_results'.format(permission_prefix), + "Download search results for {}".format(model_title_plural)) + config.add_route('{}.download_results'.format(route_prefix), '{}/download-results'.format(url_prefix)) + config.add_view(cls, attr='download_results', route_name='{}.download_results'.format(route_prefix), + permission='{}.download_results'.format(permission_prefix)) + + # download results as CSV (deprecated) if cls.results_downloadable_csv: config.add_tailbone_permission(permission_prefix, '{}.results_csv'.format(permission_prefix), "Download {} as CSV".format(model_title_plural)) config.add_route('{}.results_csv'.format(route_prefix), '{}/csv'.format(url_prefix)) config.add_view(cls, attr='results_csv', route_name='{}.results_csv'.format(route_prefix), permission='{}.results_csv'.format(permission_prefix)) + config.add_route('{}.results_csv_download'.format(route_prefix), '{}/csv/download'.format(url_prefix)) + config.add_view(cls, attr='results_csv_download', route_name='{}.results_csv_download'.format(route_prefix), + permission='{}.results_csv'.format(permission_prefix)) + # download results as XLSX (deprecated) + if cls.results_downloadable_xlsx: + config.add_tailbone_permission(permission_prefix, '{}.results_xlsx'.format(permission_prefix), + "Download {} as XLSX".format(model_title_plural)) + config.add_route('{}.results_xlsx'.format(route_prefix), '{}/xlsx'.format(url_prefix)) + config.add_view(cls, attr='results_xlsx', route_name='{}.results_xlsx'.format(route_prefix), + permission='{}.results_xlsx'.format(permission_prefix)) + config.add_route('{}.results_xlsx_download'.format(route_prefix), '{}/xlsx/download'.format(url_prefix)) + config.add_view(cls, attr='results_xlsx_download', route_name='{}.results_xlsx_download'.format(route_prefix), + permission='{}.results_xlsx'.format(permission_prefix)) + + # download rows for results + if cls.has_rows and cls.results_rows_downloadable: + config.add_tailbone_permission(permission_prefix, '{}.download_results_rows'.format(permission_prefix), + "Download *rows* for {} search results".format(model_title)) + config.add_route('{}.download_results_rows'.format(route_prefix), '{}/download-rows-for-results'.format(url_prefix)) + 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), + "Do a \"quickie search\" for {}".format(model_title_plural)) + config.add_route('{}.quickie'.format(route_prefix), '{}/quickie'.format(route_prefix), + request_method='GET') + config.add_view(cls, attr='quickie', route_name='{}.quickie'.format(route_prefix), + permission='{}.quickie'.format(permission_prefix)) + + # autocomplete + if cls.supports_autocomplete: + + # default + config.add_route('{}.autocomplete'.format(route_prefix), + '{}/autocomplete'.format(url_prefix)) + config.add_view(cls, attr='autocomplete', + route_name='{}.autocomplete'.format(route_prefix), + renderer='json', + permission='{}.list'.format(permission_prefix)) + + # special + config.add_route('{}.autocomplete_special'.format(route_prefix), + '{}/autocomplete/{{key}}'.format(url_prefix)) + config.add_view(cls, attr='autocomplete', + route_name='{}.autocomplete_special'.format(route_prefix), + renderer='json', + permission='{}.list'.format(permission_prefix)) # create - if cls.creatable or cls.mobile_creatable: + if cls.creatable: config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix), "Create new {}".format(model_title)) - if cls.creatable: config.add_route('{}.create'.format(route_prefix), '{}/new'.format(url_prefix)) config.add_view(cls, attr='create', route_name='{}.create'.format(route_prefix), permission='{}.create'.format(permission_prefix)) - if cls.mobile_creatable: - config.add_route('mobile.{}.create'.format(route_prefix), '/mobile{}/new'.format(url_prefix)) - config.add_view(cls, attr='mobile_create', route_name='mobile.{}.create'.format(route_prefix), - permission='{}.create'.format(permission_prefix)) # populate new object if cls.populatable: @@ -1981,9 +5781,32 @@ class MasterView(View): config.add_view(cls, attr='populate', route_name='{}.populate'.format(route_prefix), permission='{}.create'.format(permission_prefix)) + # enable/disable set + if cls.supports_set_enabled_toggle: + config.add_tailbone_permission(permission_prefix, '{}.enable_disable_set'.format(permission_prefix), + "Enable / disable set (selection) of {}".format(model_title_plural)) + config.add_route('{}.enable_set'.format(route_prefix), '{}/enable-set'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='enable_set', route_name='{}.enable_set'.format(route_prefix), + permission='{}.enable_disable_set'.format(permission_prefix)) + config.add_route('{}.disable_set'.format(route_prefix), '{}/disable-set'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='disable_set', route_name='{}.disable_set'.format(route_prefix), + permission='{}.enable_disable_set'.format(permission_prefix)) + + # delete set + if cls.set_deletable: + config.add_tailbone_permission(permission_prefix, '{}.delete_set'.format(permission_prefix), + "Delete set (selection) of {}".format(model_title_plural)) + config.add_route('{}.delete_set'.format(route_prefix), '{}/delete-set'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='delete_set', route_name='{}.delete_set'.format(route_prefix), + permission='{}.delete_set'.format(permission_prefix)) + # bulk delete if cls.bulk_deletable: - config.add_route('{}.bulk_delete'.format(route_prefix), '{}/bulk-delete'.format(url_prefix)) + config.add_route('{}.bulk_delete'.format(route_prefix), '{}/bulk-delete'.format(url_prefix), + request_method='POST') config.add_view(cls, attr='bulk_delete', route_name='{}.bulk_delete'.format(route_prefix), permission='{}.bulk_delete'.format(permission_prefix)) config.add_tailbone_permission(permission_prefix, '{}.bulk_delete'.format(permission_prefix), @@ -1997,50 +5820,63 @@ 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)) + cls._defaults_view(config) - # 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), + # image + if cls.has_image: + config.add_route('{}.image'.format(route_prefix), '{}/image'.format(instance_url_prefix)) + config.add_view(cls, attr='image', route_name='{}.image'.format(route_prefix), permission='{}.view'.format(permission_prefix)) - # view by record key - config.add_route('{}.view'.format(route_prefix), '{}/{{{}}}'.format(url_prefix, model_key)) - config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix), + # thumbnail + if cls.has_thumbnail: + config.add_route('{}.thumbnail'.format(route_prefix), '{}/thumbnail'.format(instance_url_prefix)) + config.add_view(cls, attr='thumbnail', route_name='{}.thumbnail'.format(route_prefix), permission='{}.view'.format(permission_prefix)) - if cls.supports_mobile: - config.add_route('mobile.{}.view'.format(route_prefix), '/mobile{}/{{{}}}'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_view', route_name='mobile.{}.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(url_prefix, model_key)) - 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(url_prefix, model_key)) - config.add_view(cls, attr='view_version', route_name='{}.version'.format(route_prefix), - permission='{}.versions'.format(permission_prefix)) # clone if cls.cloneable: config.add_tailbone_permission(permission_prefix, '{}.clone'.format(permission_prefix), "Clone an existing {0} as a new {0}".format(model_title)) - config.add_route('{}.clone'.format(route_prefix), '{}/{{{}}}/clone'.format(url_prefix, model_key)) + config.add_route('{}.clone'.format(route_prefix), '{}/clone'.format(instance_url_prefix)) config.add_view(cls, attr='clone', route_name='{}.clone'.format(route_prefix), permission='{}.clone'.format(permission_prefix)) + # touch + 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), + # 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)) + # download if cls.downloadable: - config.add_route('{}.download'.format(route_prefix), '{}/{{{}}}/download'.format(url_prefix, model_key)) + config.add_route('{}.download'.format(route_prefix), '{}/download'.format(instance_url_prefix)) config.add_view(cls, attr='download', route_name='{}.download'.format(route_prefix), permission='{}.download'.format(permission_prefix)) config.add_tailbone_permission(permission_prefix, '{}.download'.format(permission_prefix), @@ -2048,28 +5884,35 @@ class MasterView(View): # edit if cls.editable: - config.add_route('{0}.edit'.format(route_prefix), '{0}/{{{1}}}/edit'.format(url_prefix, model_key)) - config.add_view(cls, attr='edit', route_name='{0}.edit'.format(route_prefix), - permission='{0}.edit'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{0}.edit'.format(permission_prefix), - "Edit {0}".format(model_title)) + config.add_tailbone_permission(permission_prefix, '{}.edit'.format(permission_prefix), + "Edit {}".format(model_title)) + config.add_route('{}.edit'.format(route_prefix), '{}/edit'.format(instance_url_prefix)) + config.add_view(cls, attr='edit', route_name='{}.edit'.format(route_prefix), + permission='{}.edit'.format(permission_prefix)) # execute 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(url_prefix, model_key)) + 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)) # delete if cls.deletable: - config.add_route('{0}.delete'.format(route_prefix), '{0}/{{{1}}}/delete'.format(url_prefix, model_key)) + config.add_route('{0}.delete'.format(route_prefix), '{}/delete'.format(instance_url_prefix)) config.add_view(cls, attr='delete', route_name='{0}.delete'.format(route_prefix), permission='{0}.delete'.format(permission_prefix)) config.add_tailbone_permission(permission_prefix, '{0}.delete'.format(permission_prefix), "Delete {0}".format(model_title)) + # import batch from file + if cls.supports_import_batch_from_file: + config.add_tailbone_permission(permission_prefix, '{}.import_file'.format(permission_prefix), + "Create a new import batch from data file") + ### sub-rows stuff follows # download row results as CSV @@ -2080,49 +5923,289 @@ class MasterView(View): config.add_view(cls, attr='row_results_csv', route_name='{}.row_results_csv'.format(route_prefix), permission='{}.row_results_csv'.format(permission_prefix)) + # download row results as Excel + if cls.has_rows and cls.rows_downloadable_xlsx: + config.add_tailbone_permission(permission_prefix, '{}.row_results_xlsx'.format(permission_prefix), + "Download {} results as XLSX".format(row_model_title)) + config.add_route('{}.row_results_xlsx'.format(route_prefix), '{}/rows-xlsx'.format(instance_url_prefix)) + config.add_view(cls, attr='row_results_xlsx', route_name='{}.row_results_xlsx'.format(route_prefix), + permission='{}.row_results_xlsx'.format(permission_prefix)) + # create row if cls.has_rows: - if cls.rows_creatable or cls.mobile_rows_creatable: + if cls.rows_creatable: config.add_tailbone_permission(permission_prefix, '{}.create_row'.format(permission_prefix), "Create new {} rows".format(model_title)) - if cls.rows_creatable: - config.add_route('{}.create_row'.format(route_prefix), '{}/{{{}}}/new-row'.format(url_prefix, model_key)) + config.add_route('{}.create_row'.format(route_prefix), '{}/new-row'.format(instance_url_prefix)) config.add_view(cls, attr='create_row', route_name='{}.create_row'.format(route_prefix), permission='{}.create_row'.format(permission_prefix)) - if cls.mobile_rows_creatable: - config.add_route('mobile.{}.create_row'.format(route_prefix), '/mobile{}/{{{}}}/new-row'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_create_row', route_name='mobile.{}.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'.format(row_route_prefix), '{}/{{uuid}}'.format(row_url_prefix)) - config.add_view(cls, attr='view_row', route_name='{}.view'.format(row_route_prefix), - permission='{}.view'.format(permission_prefix)) - if cls.mobile_rows_viewable: - config.add_route('mobile.{}.view'.format(row_route_prefix), '/mobile{}/{{uuid}}'.format(row_url_prefix)) - config.add_view(cls, attr='mobile_view_row', route_name='mobile.{}.view'.format(row_route_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 or cls.mobile_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)) + "Edit individual {}".format(row_model_title_plural)) if cls.rows_editable: - config.add_route('{}.edit'.format(row_route_prefix), '{}/{{uuid}}/edit'.format(row_url_prefix)) - config.add_view(cls, attr='edit_row', route_name='{}.edit'.format(row_route_prefix), - permission='{}.edit_row'.format(permission_prefix)) - if cls.mobile_rows_editable: - config.add_route('mobile.{}.edit'.format(row_route_prefix), '/mobile{}/{{uuid}}/edit'.format(row_url_prefix)) - config.add_view(cls, attr='mobile_edit_row', route_name='mobile.{}.edit'.format(row_route_prefix), + 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)) # delete row - if cls.has_rows and cls.rows_deletable: - config.add_route('{}.delete'.format(row_route_prefix), '{}/{{uuid}}/delete'.format(row_url_prefix)) - config.add_view(cls, attr='delete_row', route_name='{}.delete'.format(row_route_prefix), - permission='{}.delete_row'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix), - "Delete individual {} rows".format(model_title)) + if cls.has_rows: + if cls.rows_deletable: + config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_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/master2.py b/tailbone/views/master2.py deleted file mode 100644 index e9f8d108..00000000 --- a/tailbone/views/master2.py +++ /dev/null @@ -1,423 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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 -""" - -from __future__ import unicode_literals, absolute_import - -import sqlalchemy_continuum as continuum - -from tailbone import grids -from tailbone.views import MasterView - - -class MasterView2(MasterView): - """ - Base "master" view class. All model master views should derive from this. - """ - sortable = True - rows_pageable = True - mobile_pageable = True - labels = {'uuid': "UUID"} - - @classmethod - def get_grid_factory(cls): - """ - Returns the grid factory or class which is to be used when creating new - grid instances. - """ - return getattr(cls, 'grid_factory', grids.Grid) - - @classmethod - def get_row_grid_factory(cls): - """ - Returns the grid factory or class which is to be used when creating new - row grid instances. - """ - return getattr(cls, 'row_grid_factory', grids.Grid) - - @classmethod - def get_version_grid_factory(cls): - """ - Returns the grid factory or class which is to be used when creating new - version grid instances. - """ - return getattr(cls, 'version_grid_factory', grids.Grid) - - @classmethod - def get_mobile_grid_factory(cls): - """ - Must return a callable to be used when creating new mobile grid - instances. Instead of overriding this, you can set - :attr:`mobile_grid_factory`. Default factory is :class:`MobileGrid`. - """ - return getattr(cls, 'mobile_grid_factory', grids.MobileGrid) - - @classmethod - def get_mobile_row_grid_factory(cls): - """ - Must return a callable to be used when creating new mobile row grid - instances. Instead of overriding this, you can set - :attr:`mobile_row_grid_factory`. Default factory is :class:`MobileGrid`. - """ - return getattr(cls, 'mobile_row_grid_factory', grids.MobileGrid) - - def get_effective_data(self, session=None, **kwargs): - """ - Convenience method which returns the "effective" data for the master - grid, filtered and sorted to match what would show on the UI, but not - paged etc. - """ - if session is None: - session = self.Session() - kwargs.setdefault('pageable', False) - grid = self.make_grid(session=session, **kwargs) - return grid.make_visible_data() - - def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Creates a new grid instance - """ - if factory is None: - factory = self.get_grid_factory() - if key is None: - key = self.get_grid_key() - if data is None: - data = self.get_data(session=kwargs.get('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) - self.configure_grid(grid) - grid.load_settings() - return grid - - def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Make and return a new (configured) rows grid instance. - """ - instance = kwargs.pop('instance', None) - if not instance: - instance = self.get_instance() - - if factory is None: - factory = self.get_row_grid_factory() - if key is None: - key = self.get_row_grid_key() - if data is None: - data = self.get_row_data(instance) - 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) - self.configure_row_grid(grid) - grid.load_settings() - return grid - - def make_version_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Creates a new version grid instance - """ - instance = kwargs.pop('instance', None) - if not instance: - instance = self.get_instance() - - if factory is None: - factory = self.get_version_grid_factory() - if key is None: - key = self.get_version_grid_key() - if data is None: - data = self.get_version_data(instance) - 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) - self.configure_version_grid(grid) - grid.load_settings() - return grid - - def make_mobile_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Creates a new mobile grid instance - """ - if factory is None: - factory = self.get_mobile_grid_factory() - if key is None: - key = self.get_mobile_grid_key() - if data is None: - data = self.get_mobile_data(session=kwargs.get('session')) - if columns is None: - columns = self.get_mobile_grid_columns() - - kwargs.setdefault('request', self.request) - kwargs.setdefault('mobile', True) - kwargs = self.make_mobile_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) - self.configure_mobile_grid(grid) - grid.load_settings() - return grid - - def make_mobile_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): - """ - Make a new (configured) rows grid instance for mobile. - """ - instance = kwargs.pop('instance', self.get_instance()) - - if factory is None: - factory = self.get_mobile_row_grid_factory() - if key is None: - key = 'mobile.{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()]) - if data is None: - data = self.get_mobile_row_data(instance) - if columns is None: - columns = self.get_mobile_row_grid_columns() - - kwargs.setdefault('request', self.request) - kwargs.setdefault('mobile', True) - kwargs = self.make_mobile_row_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) - self.configure_mobile_row_grid(grid) - grid.load_settings() - return grid - - def get_grid_columns(self): - if hasattr(self, 'grid_columns'): - return self.grid_columns - # TODO - raise NotImplementedError - - def get_row_grid_columns(self): - if hasattr(self, 'row_grid_columns'): - return self.row_grid_columns - # TODO - raise NotImplementedError - - def get_version_grid_columns(self): - if hasattr(self, 'version_grid_columns'): - return self.version_grid_columns - # TODO - return [ - 'issued_at', - 'user', - 'remote_addr', - 'comment', - ] - - def get_mobile_grid_columns(self): - if hasattr(self, 'mobile_grid_columns'): - return self.mobile_grid_columns - # TODO - return ['listitem'] - - def get_mobile_row_grid_columns(self): - if hasattr(self, 'mobile_row_grid_columns'): - return self.mobile_row_grid_columns - # TODO - return ['listitem'] - - def make_grid_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new grid instances. - """ - defaults = { - 'model_class': getattr(self, 'model_class', None), - 'width': 'full', - 'filterable': self.filterable, - 'sortable': self.sortable, - 'pageable': self.pageable, - 'extra_row_class': self.grid_extra_class, - 'url': lambda obj: self.get_action_url('view', obj), - 'checkboxes': self.checkboxes or ( - self.mergeable and self.request.has_perm('{}.merge'.format(self.get_permission_prefix()))), - 'checked': self.checked, - } - 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 - defaults.update(kwargs) - return defaults - - def make_row_grid_kwargs(self, **kwargs): - """ - Return a dict of kwargs to be used when constructing a new rows grid. - """ - route_prefix = self.get_row_route_prefix() - permission_prefix = self.get_permission_prefix() - - defaults = { - 'model_class': self.model_row_class, - 'width': 'full', - 'filterable': self.rows_filterable, - 'sortable': self.rows_sortable, - 'pageable': self.rows_pageable, - 'default_pagesize': self.rows_default_pagesize, - 'extra_row_class': self.row_grid_extra_class, - } - - if self.has_rows and 'main_actions' not in defaults: - actions = [] - - # view action - if self.rows_viewable: - view = lambda r, i: self.get_row_action_url('view', r) - actions.append(grids.GridAction('view', icon='zoomin', url=view)) - - # edit action - if self.rows_editable: - actions.append(grids.GridAction('edit', icon='pencil', url=self.row_edit_action_url)) - - # delete action - if self.rows_deletable and self.request.has_perm('{}.delete_row'.format(permission_prefix)): - actions.append(grids.GridAction('delete', icon='trash', url=self.row_delete_action_url)) - defaults['delete_speedbump'] = self.rows_deletable_speedbump - - defaults['main_actions'] = actions - - defaults.update(kwargs) - return defaults - - def make_version_grid_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when - constructing a new version grid. - """ - defaults = { - 'model_class': continuum.transaction_class(self.get_model_class()), - 'width': 'full', - 'pageable': True, - } - if 'main_actions' not in kwargs: - route = '{}.version'.format(self.get_route_prefix()) - instance = kwargs.get('instance') or self.get_instance() - url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) - defaults['main_actions'] = [ - self.make_action('view', icon='zoomin', url=url), - ] - defaults.update(kwargs) - return defaults - - def make_mobile_grid_kwargs(self, **kwargs): - """ - Must return a dictionary of kwargs to be passed to the factory when - creating new mobile grid instances. - """ - defaults = { - 'model_class': getattr(self, 'model_class', None), - 'pageable': self.mobile_pageable, - 'sortable': False, - 'filterable': self.mobile_filterable, - 'renderers': self.make_mobile_grid_renderers(), - 'url': lambda obj: self.get_action_url('view', obj, mobile=True), - } - # TODO: this seems wrong.. - if self.mobile_filterable: - defaults['filters'] = self.make_mobile_filters() - defaults.update(kwargs) - return defaults - - def make_mobile_row_grid_kwargs(self, **kwargs): - """ - Must return a dictionary of kwargs to be passed to the factory when - creating new mobile *row* grid instances. - """ - defaults = { - 'model_class': self.model_row_class, - # TODO - 'pageable': self.pageable, - 'sortable': False, - 'filterable': self.mobile_rows_filterable, - 'renderers': self.make_mobile_row_grid_renderers(), - 'url': lambda obj: self.get_row_action_url('view', obj, mobile=True), - } - # TODO: this seems wrong.. - if self.mobile_rows_filterable: - defaults['filters'] = self.make_mobile_row_filters() - defaults.update(kwargs) - return defaults - - def make_mobile_grid_renderers(self): - return { - 'listitem': self.render_mobile_listitem, - } - - def render_mobile_listitem(self, obj, i): - return obj - - def make_mobile_row_grid_renderers(self): - return { - 'listitem': self.render_mobile_row_listitem, - } - - def render_mobile_row_listitem(self, obj, i): - return obj - - 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 row_grid_extra_class(self, obj, i): - """ - Returns string of extra class(es) for the table row corresponding to - the given row object, or ``None``. - """ - - def set_labels(self, obj): - for key, label in self.labels.items(): - obj.set_label(key, label) - - def configure_grid(self, grid): - self.set_labels(grid) - - def configure_row_grid(self, grid): - pass - - def configure_version_grid(self, g): - g.default_sortkey = 'issued_at' - g.default_sortdir = 'desc' - g.set_renderer('comment', self.render_version_comment) - g.set_label('issued_at', "Changed") - g.set_label('user', "Changed by") - g.set_label('remote_addr', "IP Address") - # TODO: why does this render '#' as url? - # g.set_link('issued_at') - - def render_version_comment(self, transaction, column): - return transaction.meta.get('comment', "") - - def configure_mobile_grid(self, grid): - pass - - def configure_mobile_row_grid(self, grid): - pass diff --git a/tailbone/views/master3.py b/tailbone/views/master3.py deleted file mode 100644 index d9e9ac3b..00000000 --- a/tailbone/views/master3.py +++ /dev/null @@ -1,169 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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 -""" - -from __future__ import unicode_literals, absolute_import - -import os - -from sqlalchemy import orm - -import deform -from webhelpers2.html import tags - -from tailbone import forms2 as forms -from tailbone.views import MasterView2 - - -class MasterView3(MasterView2): - """ - Base "master" view class. All model master views should derive from this. - """ - - @classmethod - def get_form_factory(cls): - """ - Returns the grid factory or class which is to be used when creating new - grid instances. - """ - return getattr(cls, 'form_factory', forms.Form) - - def make_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): - """ - Creates a new form for the given model class/instance - """ - if factory is None: - factory = self.get_form_factory() - if fields is None: - fields = self.get_form_fields() - if schema is None: - schema = self.make_form_schema() - - if not self.creating: - kwargs['model_instance'] = instance - kwargs = self.make_form_kwargs(**kwargs) - form = factory(fields, schema, **kwargs) - self.configure_form(form) - return form - - def make_form_schema(self): - if not self.model_class: - # TODO - raise NotImplementedError - - def get_form_fields(self): - if hasattr(self, 'form_fields'): - return self.form_fields - # TODO - # raise NotImplementedError - - def make_form_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new form instances. - """ - defaults = { - 'request': self.request, - 'readonly': self.viewing, - 'model_class': getattr(self, 'model_class', None), - 'action_url': self.request.current_route_url(_query=None), - } - 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 configure_form(self, form): - """ - Configure the primary form. By default this just sets any primary key - fields to be readonly (if we have a :attr:`model_class`). - """ - - if self.editing and self.model_class: - mapper = orm.class_mapper(self.model_class) - for key in mapper.primary_key: - for field in form.fields: - if field == key.name: - form.set_readonly(field) - break - - form.remove_field('uuid') - - self.set_labels(form) - - def render_file_field(self, path, url=None, filename=None): - """ - Convenience for rendering a file with optional download link - """ - if not filename: - filename = os.path.basename(path) - content = "{} ({})".format(filename, self.readable_size(path)) - if url: - return tags.link_to(content, url) - return content - - def readable_size(self, path): - # TODO: this was shamelessly copied from FormAlchemy ... - length = self.get_size(path) - if length == 0: - return '0 KB' - if length <= 1024: - return '1 KB' - if length > 1048576: - return '%0.02f MB' % (length / 1048576.0) - return '%0.02f KB' % (length / 1024.0) - - def get_size(self, path): - try: - return os.path.getsize(path) - except os.error: - return 0 - - def validate_form(self, form): - controls = self.request.POST.items() - try: - self.form_deserialized = form.validate(controls) - except deform.ValidationFailure: - return False - return True - - def save_create_form(self, form): - self.before_create(form) - obj = form.schema.objectify(self.form_deserialized) - self.before_create_flush(obj, form) - self.Session.add(obj) - self.Session.flush() - return obj - - def before_create_flush(self, obj, form): - pass - - def save_edit_form(self, form): - obj = form.schema.objectify(self.form_deserialized, context=form.model_instance) - self.after_edit(obj) - self.Session.flush() diff --git a/tailbone/views/members.py b/tailbone/views/members.py new file mode 100644 index 00000000..46ed7e4b --- /dev/null +++ b/tailbone/views/members.py @@ -0,0 +1,583 @@ +# -*- 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/>. +# +################################################################################ +""" +Member Views +""" + +from collections import OrderedDict + +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, forms +from tailbone.views import MasterView + + +class MembershipTypeView(MasterView): + """ + Master view for Membership Types + """ + model_class = MembershipType + route_prefix = 'membership_types' + url_prefix = '/membership-types' + has_versions = True + + labels = { + 'id': "ID", + } + + grid_columns = [ + 'number', + 'name', + ] + + has_rows = True + model_row_class = Member + rows_title = "Members" + + row_grid_columns = [ + '_member_key_', + 'person', + '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 = [ + '_member_key_', + 'person', + 'customer', + 'default_email', + 'default_phone', + 'membership_type', + 'active', + 'equity_total', + 'equity_current', + 'equity_payment_due', + 'joined', + 'withdrew', + ] + + 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) + + g.filters['active'].default_active = True + 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.set_sorter('phone', model.MemberPhoneNumber.number) + g.set_filter('phone', model.MemberPhoneNumber.number, + factory=grids.filters.AlchemyPhoneNumberFilter) + + # 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.set_sorter('email', model.MemberEmailAddress.address) + g.set_filter('email', model.MemberEmailAddress.address) + + # 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") + + 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().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') + + # person + if self.creating or self.editing: + if 'person' in f.fields: + f.replace('person', 'person_uuid') + people = self.Session.query(model.Person)\ + .order_by(model.Person.display_name) + values = [(p.uuid, str(p)) + for p in people] + require = False + if not require: + values.insert(0, ('', "(none)")) + f.set_widget('person_uuid', dfwidget.SelectWidget(values=values)) + f.set_label('person_uuid', "Person") + else: + f.set_readonly('person') + f.set_renderer('person', self.render_person) + + # customer + if self.creating or self.editing: + if 'customer' in f.fields: + f.replace('customer', 'customer_uuid') + customers = self.Session.query(model.Customer)\ + .order_by(model.Customer.name) + values = [(c.uuid, str(c)) + for c in customers] + require = False + if not require: + values.insert(0, ('', "(none)")) + f.set_widget('customer_uuid', dfwidget.SelectWidget(values=values)) + f.set_label('customer_uuid', "Customer") + else: + f.set_readonly('customer') + f.set_renderer('customer', self.render_customer) + + # default_email + f.set_renderer('default_email', self.render_default_email) + if not self.creating and member.emails: + f.set_default('default_email', member.emails[0].address) + + # default_phone + f.set_renderer('default_phone', self.render_default_phone) + 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', + 'equity_last_paid', + 'equity_payment_credit', + 'withdrew', + ) + + def render_equity_total(self, member, field): + app = self.get_rattail_app() + total = sum([payment.amount for payment in member.equity_payments]) + return app.render_currency(total) + + def template_kwargs_view(self, **kwargs): + """ """ + kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() + member = kwargs['instance'] + + people = OrderedDict() + person = app.get_person(member) + if person: + people.setdefault(person.uuid, person) + customer = app.get_customer(member) + if customer: + person = app.get_person(customer) + if person: + people.setdefault(person.uuid, person) + kwargs['show_profiles_people'] = list(people.values()) + + return kwargs + + def render_default_email(self, member, field): + """ """ + if member.emails: + return member.emails[0].address + + def render_default_phone(self, member, field): + """ """ + if member.phones: + return member.phones[0].number + + def render_membership_type(self, member, field): + memtype = getattr(member, field) + if not memtype: + return + text = str(memtype) + url = self.request.route_url('membership_types.view', uuid=memtype.uuid) + return tags.link_to(text, url) + + def get_row_data(self, member): + """ """ + model = self.model + return self.Session.query(model.MemberEquityPayment)\ + .filter(model.MemberEquityPayment.member == member) + + def get_parent(self, payment): + return payment.member + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.set_type('amount', 'currency') + + g.set_sort_defaults('received', 'desc') + + def row_view_action_url(self, payment, i): + return self.request.route_url('member_equity_payments.view', + uuid=payment.uuid) + + def configure_get_simple_settings(self): + """ """ + return [ + + # General + {'section': 'rattail', + 'option': 'members.key_field'}, + {'section': 'rattail', + 'option': 'members.key_label'}, + {'section': 'rattail', + 'option': 'members.straight_to_profile', + 'type': bool}, + + # Relationships + {'section': 'rattail', + 'option': 'members.max_one_per_person', + 'type': bool}, + ] + + +class MemberEquityPaymentView(MasterView): + """ + Master view for the MemberEquityPayment class. + """ + model_class = MemberEquityPayment + route_prefix = 'member_equity_payments' + url_prefix = '/member-equity-payments' + supports_grid_totals = True + has_versions = True + + labels = { + 'status_code': "Status", + } + + grid_columns = [ + 'received', + '_member_key_', + 'member', + 'amount', + 'description', + 'source', + 'transaction_identifier', + 'status_code', + ] + + form_fields = [ + '_member_key_', + 'member', + 'amount', + 'received', + 'description', + 'source', + 'transaction_identifier', + 'status_code', + ] + + def query(self, session): + """ """ + query = super().query(session) + model = self.model + + query = query.join(model.Member) + + return query + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + model = self.model + + # member_key + field = self.get_member_key_field() + attr = getattr(model.Member, field) + g.set_renderer(field, self.render_member_key) + g.set_filter(field, attr, + label=self.get_member_key_label(), + default_active=True, + default_verb='equal') + g.set_sorter(field, attr) + + # member (name) + g.set_joiner('member', lambda q: q.outerjoin(model.Person)) + g.set_sorter('member', model.Person.display_name) + g.set_link('member') + g.set_filter('member', model.Person.display_name, + label="Member Name") + + g.set_type('amount', 'currency') + + g.set_sort_defaults('received', 'desc') + g.set_link('received') + + # description + g.set_link('description') + + g.set_link('transaction_identifier') + + # status_code + g.set_enum('status_code', model.MemberEquityPayment.STATUS) + + def render_member_key(self, payment, field): + key = getattr(payment.member, field) + return key + + def fetch_grid_totals(self): + app = self.get_rattail_app() + results = self.get_effective_data() + total = sum([payment.amount for payment in results]) + return {'totals_display': app.render_currency(total)} + + def configure_form(self, f): + """ """ + super().configure_form(f) + model = self.model + payment = f.model_instance + + # member_key + field = self.get_member_key_field() + f.set_renderer(field, self.render_member_key) + f.set_readonly(field) + + # member + if self.creating: + f.replace('member', 'member_uuid') + member_display = "" + if self.request.method == 'POST': + if self.request.POST.get('member_uuid'): + member = self.Session.get(model.Member, + self.request.POST['member_uuid']) + if member: + member_display = str(member) + elif self.editing: + member_display = str(payment.member or '') + members_url = self.request.route_url('members.autocomplete') + f.set_widget('member_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=member_display, service_url=members_url)) + f.set_label('member_uuid', "Member") + else: + f.set_readonly('member') + f.set_renderer('member', self.render_member) + + # amount + f.set_type('amount', 'currency') + + # received + if self.creating: + f.set_type('received', 'date_jquery') + else: + f.set_readonly('received') + + # status_code + f.set_enum('status_code', model.MemberEquityPayment.STATUS) + + def get_version_diff_enums(self, version): + """ """ + model = self.model + cls = continuum.parent_class(version.__class__) + + if cls is model.MemberEquityPayment: + return {'status_code': model.MemberEquityPayment.STATUS} + + +def defaults(config, **kwargs): + base = globals() + + MembershipTypeView = kwargs.get('MembershipTypeView', base['MembershipTypeView']) + MembershipTypeView.defaults(config) + + MemberView = kwargs.get('MemberView', base['MemberView']) + MemberView.defaults(config) + + MemberEquityPaymentView = kwargs.get('MemberEquityPaymentView', base['MemberEquityPaymentView']) + MemberEquityPaymentView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py 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 087837ec..9199c025 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.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,82 +24,19 @@ Message Views """ -from __future__ import unicode_literals, absolute_import - -import json -import pytz -import six - -from rattail import enum from rattail.db import model from rattail.time import localtime -import formalchemy -from formalchemy.helpers import text_field -from pyramid import httpexceptions +import colander +from deform import widget as dfwidget from webhelpers2.html import tags, HTML -from tailbone import forms from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView from tailbone.util import raw_datetime -class SenderFieldRenderer(forms.renderers.UserFieldRenderer): - - def render_readonly(self, **kwargs): - sender = self.raw_value - if sender is self.request.user: - return 'you' - return super(SenderFieldRenderer, self).render_readonly(**kwargs) - - -class RecipientsField(formalchemy.Field): - """ - Custom field for recipients, used when sending new messages. - """ - is_collection = True - - def sync(self): - if not self.is_readonly(): - message = self.parent.model - for uuid in self._deserialize(): - user = Session.query(model.User).get(uuid) - if user: - message.add_recipient(user, status=enum.MESSAGE_STATUS_INBOX) - - -class RecipientsFieldRenderer(formalchemy.FieldRenderer): - - def render(self, **kwargs): - uuids = self.value - value = ','.join(uuids) if uuids else '' - return text_field(self.name, value=value, **kwargs) - - def deserialize(self): - value = self.params.getone(self.name).split(',') - value = [uuid.strip() for uuid in value] - value = set([uuid for uuid in value if uuid]) - return value - - def render_readonly(self, **kwargs): - recipients = self.raw_value - if not recipients: - return '' - recips = [r for r in recipients if r.recipient is not self.request.user] - recips = sorted([r.recipient.display_name for r in recips]) - if len(recips) < len(recipients): - recips.insert(0, 'you') - max_display = 5 - if len(recips) > max_display: - 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 - return ', '.join(recips) - - -class MessagesView(MasterView): +class MessageView(MasterView): """ Base class for message views. """ @@ -109,7 +46,22 @@ class MessagesView(MasterView): checkboxes = True replying = False reply_header_sent_format = '%a %d %b %Y at %I:%M %p' - grid_columns = ['subject', 'sender', 'recipients', 'sent'] + listable = False + + grid_columns = [ + 'subject', + 'sender', + 'recipients', + 'sent', + ] + + form_fields = [ + 'sender', + 'recipients', + 'sent', + 'subject', + 'body', + ] def get_index_title(self): if self.listing: @@ -117,7 +69,7 @@ class MessagesView(MasterView): if self.viewing: message = self.get_instance() recipient = self.get_recipient(message) - if recipient and recipient.status == enum.MESSAGE_STATUS_ARCHIVE: + if recipient and recipient.status == self.enum.MESSAGE_STATUS_ARCHIVE: return "Message Archive" elif not recipient: return "Sent Messages" @@ -129,15 +81,15 @@ class MessagesView(MasterView): def index(self): if not self.request.user: - raise httpexceptions.HTTPForbidden - return super(MessagesView, self).index() + raise self.forbidden() + return super().index() def get_instance(self): if not self.request.user: - raise httpexceptions.HTTPForbidden - message = super(MessagesView, 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): @@ -154,17 +106,23 @@ class MessagesView(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' - g.default_sortkey = 'sent' - g.default_sortdir = 'desc' + g.set_sort_defaults('sent', 'desc') g.set_renderer('sent', self.render_sent) g.set_renderer('sender', self.render_sender) @@ -178,11 +136,16 @@ class MessagesView(MasterView): def render_sent(self, message, column_name): return raw_datetime(self.rattail_config, message.sent) - def render_sender(self, message, column_name): + def render_sender(self, message, field): 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: + return "" + return HTML.tag('span', c=message.subject, style='font-weight: bold;') def render_recipients(self, message, column_name): recipients = message.recipients @@ -197,50 +160,96 @@ class MessagesView(MasterView): return "{}, ...".format(', '.join(recips[:4])) return "" - def make_form(self, instance, **kwargs): - form = super(MessagesView, self).make_form(instance, **kwargs) - if self.creating: - form.id = 'new-message' - form.cancel_url = self.request.get_referrer(default=self.request.route_url('messages.inbox')) - form.create_label = "Send Message" - return form + def render_recipients_full(self, message, field): + recipients = message.recipients + if not recipients: + return "" + + # 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] + + # sort recipients by display name + recips = sorted([r.recipient.display_name for r in recips]) + + # if we *did* remove current user from list, insert them at front of list + if len(recips) < len(recipients) and ( + message.sender is not self.request.user or not recips): + recips.insert(0, 'you') + + # we only want to show the first 5 recipients by default, with + # client-side JS allowing the user to view all if they want + max_display = 5 + if len(recips) > max_display: + 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) + + # TODO!! + # def make_form(self, instance, **kwargs): + # form = super(MessageView, self).make_form(instance, **kwargs) + # if self.creating: + # form.id = 'new-message' + # form.cancel_url = self.request.get_referrer(default=self.request.route_url('messages.inbox')) + # form.create_label = "Send Message" + # return form + + def configure_form(self, f): + super().configure_form(f) + + f.submit_label = "Send Message" - def configure_fieldset(self, fs): # TODO: A fair amount of this still seems hacky... + f.set_renderer('sender', self.render_sender) + f.set_label('sender', "From") + + f.set_type('sent', 'datetime') + + # recipients + f.set_renderer('recipients', self.render_recipients_full) + f.set_label('recipients', "To") + + # 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') - # Must create a new 'sender' field so that we can feed it the - # current user as default value, but prevent attaching user to the - # (new) underlying message instance...ugh - fs.append(formalchemy.Field('sender', value=self.request.user, - renderer=forms.renderers.UserFieldRenderer, - label="From", readonly=True)) + # body + f.set_widget('body', dfwidget.TextAreaWidget( + cols=50, rows=15, attributes={'ref': 'messageBody'})) - # Sort of the same thing for recipients, although most of that logic is below. - fs.append(RecipientsField('recipients', label="To", renderer=RecipientsFieldRenderer)) + if self.creating: + f.remove('sender', 'sent') - fs.configure(include=[ - fs.sender, - fs.recipients, - fs.subject, - fs.body.textarea(size='50x15'), - ]) - - # We'll assign some properties directly on the new message; - # apparently that's safe and won't cause it to be committed. - # Notably, we can't assign the sender yet. Also the actual - # recipients assignment is handled by that field's sync(). - message = fs.model + # recipients + f.insert_after('recipients', 'set_recipients') + f.remove('recipients') + f.set_node('set_recipients', colander.SchemaNode(colander.Set())) + f.set_widget('set_recipients', RecipientsWidget()) + f.set_label('set_recipients', "To") if self.replying: old_message = self.get_instance() - message.subject = "Re: {}".format(old_message.subject) - message.body = self.get_reply_body(old_message) + f.set_default('subject', "Re: {}".format(old_message.subject)) + f.set_default('body', self.get_reply_body(old_message)) - # Determine an initial set of recipients, based on reply - # method. This value will be set to a 'pseudo' field to avoid - # touching the new model instance and causing a crap commit. + # Determine an initial set of recipients, based on reply method. # If replying to all, massage the list a little so that the # current user is not listed, and the sender is listed first. @@ -250,37 +259,38 @@ class MessagesView(MasterView): if self.filter_reply_recipient(r.recipient)] value = dict(value) value.pop(self.request.user.uuid, None) - value = sorted(value.iteritems(), key=lambda r: r[1]) + value = sorted(value.items(), key=lambda r: r[1]) 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) - fs.recipients.set(value=value) + f.set_default('set_recipients', value) # Just a normal reply, to sender only. elif self.filter_reply_recipient(old_message.sender): - fs.recipients.set(value=[old_message.sender.uuid]) + f.set_default('set_recipients', [old_message.sender.uuid]) - # Set focus to message body instead of recipients, when replying. - fs.focus = fs.body + # TODO? + # # Set focus to message body instead of recipients, when replying. + # fs.focus = fs.body elif self.viewing: + f.remove('body') - # Viewing an existing message is a heck of a lot easier... - fs.configure(include=[ - fs.sender.with_renderer(SenderFieldRenderer).label("From"), - fs.recipients.with_renderer(RecipientsFieldRenderer).label("To"), - fs.sent, - fs.subject, - ]) + def objectify(self, form, data=None): + if data is None: + data = form.validated + message = super().objectify(form, data) - def before_create(self, form): - """ - This is where we must assign the current user as sender for new - messages, for now. I'm still not quite happy with this... - """ - super(MessagesView, self).before_create(form) - message = form.fieldset.model - message.sender = self.request.user + if self.creating: + if self.request.user: + message.sender = self.request.user + + for uuid in data['set_recipients']: + user = self.Session.get(model.User, uuid) + if user: + message.add_recipient(user, status=self.enum.MESSAGE_STATUS_INBOX) + + return message def flash_after_create(self, obj): self.request.session.flash("Message has been sent: {}".format( @@ -290,8 +300,7 @@ class MessagesView(MasterView): return user.active def get_reply_header(self, message): - sent = pytz.utc.localize(message.sent) - sent = localtime(self.rattail_config, sent) + sent = localtime(self.rattail_config, message.sent, from_utc=True) sent = sent.strftime(self.reply_header_sent_format) return "On {}, {} wrote:".format(sent, message.sender.person.display_name) @@ -317,12 +326,26 @@ class MessagesView(MasterView): return recipient def template_kwargs_create(self, **kwargs): - kwargs['available_recipients'] = self.get_available_recipients() - kwargs['json'] = json + + recips = self.get_available_recipients() + kwargs['recipient_display_map'] = recips + recips = list(recips.items()) + recips.sort(key=self.recipient_sortkey) + kwargs['available_recipients'] = recips + if self.replying: kwargs['original_message'] = self.get_instance() + + kwargs['index_url'] = None + kwargs['index_title'] = "New Message" return kwargs + def recipient_sortkey(self, recip): + uuid, entry = recip + if isinstance(entry, dict): + return entry['name'] + return entry + def get_available_recipients(self): """ Return the full mapping of recipients which may be included in a @@ -338,8 +361,15 @@ class MessagesView(MasterView): def template_kwargs_view(self, **kwargs): message = kwargs['instance'] - return {'message': message, - 'recipient': self.get_recipient(message)} + recipient = self.get_recipient(message) + + kwargs['message'] = message + kwargs['recipient'] = recipient + + if recipient and recipient.status == self.enum.MESSAGE_STATUS_ARCHIVE: + kwargs['index_url'] = self.request.route_url('messages.archive') + + return kwargs def reply(self): """ @@ -363,7 +393,7 @@ class MessagesView(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'): @@ -371,7 +401,7 @@ class MessagesView(MasterView): return self.redirect(self.request.get_referrer( default=self.request.route_url('messages_inbox'))) - new_status = enum.MESSAGE_STATUS_INBOX if dest == 'inbox' else enum.MESSAGE_STATUS_ARCHIVE + new_status = self.enum.MESSAGE_STATUS_INBOX if dest == 'inbox' else self.enum.MESSAGE_STATUS_ARCHIVE if recipient.status != new_status: recipient.status = new_status return self.redirect(self.request.route_url('messages.{}'.format( @@ -385,7 +415,7 @@ class MessagesView(MasterView): if self.request.method == 'POST': uuids = self.request.POST.get('uuids', '').split(',') if uuids: - new_status = enum.MESSAGE_STATUS_INBOX if dest == 'inbox' else enum.MESSAGE_STATUS_ARCHIVE + new_status = self.enum.MESSAGE_STATUS_INBOX if dest == 'inbox' else self.enum.MESSAGE_STATUS_ARCHIVE for uuid in uuids: recip = self.Session.query(model.MessageRecipient)\ .filter(model.MessageRecipient.message_uuid == uuid)\ @@ -422,8 +452,11 @@ class MessagesView(MasterView): cls._defaults(config) +# TODO: deprecate / remove this +MessagesView = MessageView -class InboxView(MessagesView): + +class InboxView(MessageView): """ Inbox message view. """ @@ -435,11 +468,11 @@ class InboxView(MessagesView): return self.request.route_url('messages.inbox') def query(self, session): - q = super(InboxView, self).query(session) - return q.filter(model.MessageRecipient.status == enum.MESSAGE_STATUS_INBOX) + q = super().query(session) + return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_INBOX) -class ArchiveView(MessagesView): +class ArchiveView(MessageView): """ Archived message view. """ @@ -451,11 +484,11 @@ class ArchiveView(MessagesView): return self.request.route_url('messages.archive') def query(self, session): - q = super(ArchiveView, self).query(session) - return q.filter(model.MessageRecipient.status == enum.MESSAGE_STATUS_ARCHIVE) + q = super().query(session) + return q.filter(model.MessageRecipient.status == self.enum.MESSAGE_STATUS_ARCHIVE) -class SentView(MessagesView): +class SentView(MessageView): """ Sent messages view. """ @@ -472,7 +505,7 @@ class SentView(MessagesView): .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)\ @@ -481,23 +514,60 @@ class SentView(MessagesView): default_active=True, default_verb='contains') -def includeme(config): +class RecipientsWidget(dfwidget.Widget): + """ + Custom "message recipients" widget, for use with Vue.js themes. + """ + template = 'message_recipients' - config.add_tailbone_permission('messages', 'messages.list', "List/Search Messages") + def deserialize(self, field, pstruct): + if pstruct is colander.null: + return colander.null + if not isinstance(pstruct, str): + raise colander.Invalid(field.schema, "Pstruct is not a string") + if not pstruct: + return colander.null + pstruct = pstruct.split(',') + return pstruct + + def serialize(self, field, cstruct, **kw): + if cstruct in (colander.null, None): + cstruct = "" + template = self.template + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + + +def defaults(config, **kwargs): + base = globals() + + 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') - MessagesView.defaults(config) + 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 203c4382..405b1ca3 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.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,66 +24,49 @@ 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 import api +from rattail.db.model import Person, PersonNote, MergePeopleRequest +from rattail.util import simple_error -import formalchemy as fa -from pyramid.httpexceptions import HTTPFound, HTTPNotFound +import colander from webhelpers2.html import HTML, tags -from tailbone import forms -from tailbone.views import MasterView2 as MasterView, AutocompleteView +from tailbone import forms, grids +from tailbone.db import TrainwreckSession +from tailbone.views import MasterView +from tailbone.util import raw_datetime -class CustomersFieldRenderer(fa.FieldRenderer): - - def render_readonly(self, **kwargs): - customers = self.raw_value - if not customers: - return '' - - items = [] - for customer in customers: - customer = customer.customer - text = six.text_type(customer) - if customer.id: - text = "({}) {}".format(customer.id, text) - elif customer.number: - text = "({}) {}".format(customer.number, text) - items.append(HTML.tag('li', c=tags.link_to(text, self.request.route_url('customers.view', uuid=customer.uuid)))) - - return HTML.tag('ul', c=items) +log = logging.getLogger(__name__) -class UsersFieldRenderer(fa.FieldRenderer): - - def render_readonly(self, **kwargs): - users = self.raw_value - items = [] - for user in users: - text = user.username - url = self.request.route_url('users.view', uuid=user.uuid) - items.append(HTML.tag('li', c=tags.link_to(text, url))) - if items: - return HTML.tag('ul', c=items) - elif self.request.has_perm('users.create'): - return HTML.tag('button', type='button', id='make-user', c="Make User") - else: - return "" - - -class PeopleView(MasterView): +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 + supports_autocomplete = True + supports_quickie_search = True + configurable = True + + labels = { + 'default_phone': "Phone Number", + 'default_email': "Email Address", + } grid_columns = [ 'display_name', @@ -91,23 +74,81 @@ class PeopleView(MasterView): 'last_name', 'phone', 'email', + 'merge_requested', ] + form_fields = [ + 'first_name', + 'middle_name', + 'last_name', + 'display_name', + 'default_phone', + 'default_email', + 'address', + 'employee', + 'customers', + 'members', + 'users', + ] + + mergeable = True + + def __init__(self, request): + super().__init__(request) + app = self.get_rattail_app() + + # always get a reference to the 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().make_grid_kwargs(**kwargs) + + # turn on checkboxes if user can create a merge reqeust + if self.mergeable and self.has_perm('request_merge'): + kwargs['checkboxes'] = True + + return kwargs + def configure_grid(self, g): - super(PeopleView, 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) - g.filters['phone'] = g.make_filter('phone', model.PersonPhoneNumber.number) + # 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' @@ -115,67 +156,288 @@ class PeopleView(MasterView): g.filters['last_name'].default_active = True g.filters['last_name'].default_verb = 'contains' - 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_joiner('employee_status', lambda q: q.outerjoin(model.Employee)) + g.set_filter('employee_status', model.Employee.status, + value_enum=self.enum.EMPLOYEE_STATUS) - g.default_sortkey = 'display_name' + 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)\ + .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 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: + if self.user_is_protected(user): + return True + return False def editable_instance(self, person): - if self.rattail_config.demo(): - return not bool(person.user and person.user.username == 'chuck') - return True + if self.request.is_root: + return True + return not self.is_person_protected(person) def deletable_instance(self, person): - if self.rattail_config.demo(): - return not bool(person.user and person.user.username == 'chuck') - return True + if self.request.is_root: + return True + return not self.is_person_protected(person) - def _preconfigure_fieldset(self, fs): - fs.display_name.set(label="Full Name") - fs.phone.set(label="Phone Number", readonly=True) - fs.email.set(label="Email Address", readonly=True) - fs.address.set(label="Mailing Address", readonly=True) - fs.employee.set(renderer=forms.renderers.EmployeeFieldRenderer, attrs={'hyperlink': True}, readonly=True) - fs._customers.set(renderer=CustomersFieldRenderer, readonly=True) - fs.users.set(renderer=UsersFieldRenderer, readonly=True) + def configure_form(self, f): + super().configure_form(f) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.first_name, - fs.middle_name, - fs.last_name, - fs.display_name, - fs.phone, - fs.email, - fs.address, - fs.employee, - fs._customers, - fs.users, - ]) + # 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().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: + names['last'] = data['last_name'] + if 'display_name' in form and 'display_name' not in form.readonly_fields: + names['full'] = data['display_name'] + + # TODO: why do we find colander.null values in data at this point? + # ugh, for now we must convert them + for key in names: + if names[key] is colander.null: + names[key] = None + + # do explicit name update w/ common handler logic + self.handler.update_names(person, **names) + + return person + + def delete_instance(self, person): + """ + Supplements the default logic as follows: + + Any customer associations are first deleted for the person. Once that + is complete, deletion continues as per usual. + """ + session = orm.object_session(person) + + # must explicitly remove all CustomerPerson records + for cp in list(person._customers): + customer = cp.customer + session.delete(cp) + # session.flush() + customer._people.reorder() + + # continue with normal logic + super().delete_instance(person) + + def touch_instance(self, person): + """ + Supplements the default logic as follows: + + 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().touch_instance(person) + + def touch(obj): + change = model.Change() + change.class_name = obj.__class__.__name__ + change.instance_uuid = obj.uuid + change.deleted = False + self.Session.add(change) + + # phone numbers + for phone in person.phones: + touch(phone) + + # email addresses + for email in person.emails: + touch(email) + + # mailing addresses + for address in person.addresses: + touch(address) + + def configure_common_form(self, f): + super().configure_common_form(f) + person = f.model_instance + + f.set_label('display_name', "Full Name") + + # TODO: should remove this? + f.set_readonly('phone') + f.set_label('phone', "Phone Number") + + f.set_renderer('default_phone', self.render_default_phone) + if not self.creating and person.phones: + f.set_default('default_phone', person.phones[0].number) + + # TODO: should remove this? + f.set_readonly('email') + f.set_label('email', "Email Address") + + f.set_renderer('default_email', self.render_default_email) + if not self.creating and person.emails: + f.set_default('default_email', person.emails[0].address) + + f.set_readonly('address') + f.set_label('address', "Mailing Address") + + # employee + if self.creating: + f.remove_field('employee') + else: + f.set_readonly('employee') + f.set_renderer('employee', self.render_employee) + + # customers + if self.creating: + f.remove_field('customers') + else: + f.set_readonly('customers') + f.set_renderer('customers', self.render_customers) + + # members + if self.creating: + f.remove_field('members') + else: + f.set_readonly('members') + f.set_renderer('members', self.render_members) + + # users + if self.creating: + f.remove_field('users') + else: + f.set_readonly('users') + f.set_renderer('users', self.render_users) + + def render_employee(self, person, field): + employee = person.employee + 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_customers(self, person, field): + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + + customers = clientele.get_customers_for_account_holder(person) + if not customers: + return + + items = [] + for customer in customers: + 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): + members = person.members + if not members: + return "" + items = [] + for member in members: + text = str(member) + if member.number: + text = "(#{}) {}".format(member.number, text) + elif member.id: + text = "({}) {}".format(member.id, text) + url = self.request.route_url('members.view', uuid=member.uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) + + def render_users(self, person, field): + users = person.users + items = [] + for user in users: + text = user.username + url = self.request.route_url('users.view', uuid=user.uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + if items: + return HTML.tag('ul', c=items) + elif self.viewing and self.request.has_perm('users.create'): + 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'), @@ -185,9 +447,1226 @@ class PeopleView(MasterView): (model.VendorContact, 'person_uuid'), ] + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def do_quickie_lookup(self, entry): + app = self.get_rattail_app() + return app.get_people_handler().quickie_lookup(entry, self.Session()) + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() + + def get_quickie_result_url(self, person): + return self.get_action_url('view_profile', person) + + def view_profile(self): + """ + View which exposes the "full profile" for a given person, i.e. all + related customer, employee, user info etc. + """ + self.viewing = True + app = self.get_rattail_app() + person = self.get_instance() + + context = { + 'person': person, + 'instance': person, + 'instance_title': self.get_instance_title(person), + '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(), + '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(), + } + + if context['expose_transactions']: + context['transactions_grid'] = self.profile_transactions_grid(person, empty=True) + + if self.request.has_perm('people_profile.view_versions'): + context['revisions_grid'] = self.profile_revisions_grid(person) + + return self.render_to_response('view_profile', context) + + def should_expose_profile_members(self): + return self.rattail_config.get_bool('tailbone.people.profile.expose_members', + default=False) + + def should_expose_profile_transactions(self): + return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions', + default=False) + + def profile_transactions_grid(self, person, empty=False): + app = self.get_rattail_app() + trainwreck = app.get_trainwreck_handler() + model = trainwreck.get_model() + route_prefix = self.get_route_prefix() + if empty: + # TODO: surely there is a better way to have empty data..? but so + # much logic depends on a query, can't just pass empty list here + data = TrainwreckSession.query(model.Transaction)\ + .filter(model.Transaction.uuid == 'bogus') + else: + data = self.profile_transactions_query(person) + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.profile.transactions.{person.uuid}', + data=data, + model_class=model.Transaction, + ajax_data_url=self.get_action_url('view_profile_transactions', person), + columns=[ + 'start_time', + 'end_time', + 'system', + 'terminal_id', + 'receipt_number', + 'cashier_name', + 'customer_id', + 'customer_name', + 'total', + ], + labels={ + 'terminal_id': "Terminal", + 'customer_id': "Customer " + app.get_customer_key_label(), + }, + filterable=True, + sortable=True, + paginated=True, + default_sortkey='end_time', + default_sortdir='desc', + component='transactions-grid', + ) + if self.request.has_perm('trainwreck.transactions.view'): + url = lambda row, i: self.request.route_url('trainwreck.transactions.view', + uuid=row.uuid) + g.actions.append(self.make_action('view', icon='eye', url=url)) + g.load_settings() + + g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) + g.set_type('total', 'currency') + + return g + + def profile_transactions_query(self, person): + """ + Method which must return the base query for the profile's POS + Transactions grid data. + """ + customer = self.app.get_customer(person) + + if customer: + key_field = self.app.get_customer_key_field() + customer_key = getattr(customer, key_field) + if customer_key is not None: + customer_key = str(customer_key) + else: + # nb. this should *not* match anything, so query returns + # no results.. + customer_key = person.uuid + + trainwreck = self.app.get_trainwreck_handler() + model = trainwreck.get_model() + query = TrainwreckSession.query(model.Transaction)\ + .filter(model.Transaction.customer_id == customer_key) + return query + + def profile_transactions_data(self): + """ + AJAX view to return new sorted, filtered data for transactions + grid within profile view. + """ + person = self.get_instance() + grid = self.profile_transactions_grid(person) + return grid.get_table_data() + + def get_context_tabchecks(self, person): + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + tabchecks = {} + + # TODO: for efficiency, should only calculate checks for tabs + # actually in use by app..(how) should that be configurable? + + # personal + tabchecks['personal'] = True + + # member + if self.should_expose_profile_members(): + membership = app.get_membership_handler() + if membership.max_one_per_person(): + member = app.get_member(person) + tabchecks['member'] = bool(member and member.active) + else: + members = membership.get_members_for_account_holder(person) + tabchecks['member'] = any([m.active for m in members]) + + # customer + customers = clientele.get_customers_for_account_holder(person) + tabchecks['customer'] = bool(customers) + + # shopper + # TODO: what a hack! surely some of this belongs in handler + shoppers = person.customer_shoppers + shoppers = [shopper for shopper in shoppers + if shopper.shopper_number != 1] + tabchecks['shopper'] = bool(shoppers) + + # employee + employee = app.get_employee(person) + tabchecks['employee'] = bool(employee and employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) + + # notes + tabchecks['notes'] = bool(person.notes) + + # user + tabchecks['user'] = bool(person.users) + + return tabchecks + + def profile_changed_response(self, person): + """ + Return common context result for all AJAX views which may + change the profile details. This is enough to update the + page-wide things, and let other tabs know they should be + refreshed when next displayed. + """ + return { + 'person': self.get_context_person(person), + 'tabchecks': self.get_context_tabchecks(person), + } + + def template_kwargs_view_profile(self, **kwargs): + """ + Stub method so subclass can call `super()` for it. + """ + return kwargs + + def get_max_lengths(self): + app = self.get_rattail_app() + model = self.model + 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): + """ + Returns a list of "phone type" options, for use in dropdown. + """ + # TODO: should probably define this list somewhere else + phone_types = [ + "Home", + "Mobile", + "Work", + "Other", + "Fax", + ] + return [{'value': typ, 'label': typ} + for typ in phone_types] + + def get_email_type_options(self): + """ + Returns a list of "email type" options, for use in dropdown. + """ + # TODO: should probably define this list somewhere else + email_types = [ + "Home", + "Work", + "Other", + ] + return [{'value': typ, 'label': typ} + for typ in email_types] + + def get_context_person(self, person): + + context = { + 'uuid': person.uuid, + 'first_name': person.first_name, + 'middle_name': person.middle_name, + 'last_name': person.last_name, + 'display_name': person.display_name, + 'view_url': self.get_action_url('view', person), + '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, + 'street': address.street, + 'street2': address.street2, + 'city': address.city, + 'state': address.state, + 'zipcode': address.zipcode, + 'display': str(address), + } + + model = self.model + if isinstance(address, model.PersonMailingAddress): + person = address.person + context['invalid'] = self.handler.address_is_invalid(person, address) + + 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 customer in customers: + context = { + 'uuid': customer.uuid, + '_key': getattr(customer, key), + 'id': customer.id, + 'number': customer.number, + 'name': customer.name, + 'view_url': self.request.route_url('customers.view', + uuid=customer.uuid), + '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 supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_member'): + context = supp.get_context_for_member(member, context) + + data[member.uuid] = context + + return list(data.values()) + + def get_context_member(self, member): + app = self.get_rattail_app() + person = app.get_person(member) + + 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': 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': 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. + """ + 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 + + def get_context_employee_history(self, employee): + data = [] + if employee: + for history in employee.sorted_history(reverse=True): + data.append({ + 'uuid': history.uuid, + '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 + first if necessary. + """ + app = self.get_rattail_app() + handler = app.get_clientele_handler() + 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. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + 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 self.profile_changed_response(person) + + def get_context_phones(self, person): + data = [] + for phone in person.phones: + data.append({ + 'uuid': phone.uuid, + 'type': phone.type, + 'number': phone.number, + 'preferred': phone.preferred, + 'preference': phone.preference, + }) + return data + + def profile_add_phone(self): + """ + View which adds a new phone number for the person. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + try: + phone = self.handler.add_phone(person, data['phone_number'], + type=data['phone_type'], + preferred=data['phone_preferred']) + except Exception as error: + log.warning("failed to add phone", exc_info=True) + return {'error': simple_error(error)} + + self.Session.flush() + 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.get(model.PersonPhoneNumber, data['phone_uuid']) + if not phone: + return {'error': "Phone not found."} + + kwargs = { + 'number': data['phone_number'], + 'type': data['phone_type'], + } + if 'phone_preferred' in data: + kwargs['preferred'] = data['phone_preferred'] + + try: + phone = self.handler.update_phone(person, phone, **kwargs) + except Exception as error: + log.warning("failed to update phone", exc_info=True) + return {'error': simple_error(error)} + + self.Session.flush() + 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.get(model.PersonPhoneNumber, data['phone_uuid']) + if not phone: + return {'error': "Phone not found."} + if phone not in person.phones: + return {'error': "Phone does not belong to this person."} + + # remove phone + person.remove_phone(phone) + + self.Session.flush() + 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.get(model.PersonPhoneNumber, data['phone_uuid']) + if not phone: + return {'error': "Phone not found."} + if phone not in person.phones: + return {'error': "Phone does not belong to this person."} + + # update phone preference + person.set_primary_phone(phone) + + self.Session.flush() + return self.profile_changed_response(person) + + def get_context_emails(self, person): + data = [] + for email in person.emails: + data.append({ + 'uuid': email.uuid, + 'type': email.type, + 'address': email.address, + 'invalid': email.invalid, + 'preferred': email.preferred, + 'preference': email.preference, + }) + return data + + def profile_add_email(self): + """ + View which adds a new email address for the person. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + kwargs = { + 'type': data['email_type'], + 'invalid': False, + } + if 'email_preferred' in data: + kwargs['preferred'] = data['email_preferred'] + + try: + email = self.handler.add_email(person, data['email_address'], **kwargs) + except Exception as error: + log.warning("failed to add email", exc_info=True) + return {'error': simple_error(error)} + + self.Session.flush() + 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.get(model.PersonEmailAddress, data['email_uuid']) + if not email: + return {'error': "Email not found."} + + try: + email = self.handler.update_email(person, email, + address=data['email_address'], + type=data['email_type'], + invalid=data['email_invalid']) + except Exception as error: + log.warning("failed to add email", exc_info=True) + return {'error': simple_error(error)} + + self.Session.flush() + 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.get(model.PersonEmailAddress, data['email_uuid']) + if not email: + return {'error': "Email not found."} + if email not in person.emails: + return {'error': "Email does not belong to this person."} + + # remove email + person.remove_email(email) + + self.Session.flush() + 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.get(model.PersonEmailAddress, data['email_uuid']) + if not email: + return {'error': "Email not found."} + if email not in person.emails: + return {'error': "Email does not belong to this person."} + + # update email preference + person.set_primary_email(email) + + self.Session.flush() + return self.profile_changed_response(person) + + def profile_edit_address(self): + """ + View which allows a person's mailing address to be updated. + """ + person = self.get_instance() + data = dict(self.request.json_body) + + # update person address + 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 { + '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): + """ + View which will cause the person to start being an employee. + """ + person = self.get_instance() + app = self.get_rattail_app() + handler = app.get_employment_handler() + + reason = handler.why_not_begin_employment(person) + if reason: + return {'error': reason} + + data = self.request.json_body + start_date = datetime.datetime.strptime(data['start_date'], '%Y-%m-%d').date() + employee = handler.begin_employment(person, start_date, + employee_id=data['id']) + self.Session.flush() + return self.profile_changed_response(person) + + def profile_end_employee(self): + """ + View which will cause the person to stop being an employee. + """ + person = self.get_instance() + app = self.get_rattail_app() + handler = app.get_employment_handler() + + reason = handler.why_not_end_employment(person) + if reason: + return {'error': reason} + + data = dict(self.request.json_body) + end_date = datetime.datetime.strptime(data['end_date'], '%Y-%m-%d').date() + employee = handler.get_employee(person) + handler.end_employment(employee, end_date, + revoke_access=data.get('revoke_access')) + self.Session.flush() + 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.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."} + + # all history records have a start date, so always update that + start_date = self.request.json_body['start_date'] + start_date = datetime.datetime.strptime(start_date, '%Y-%m-%d').date() + history.start_date = start_date + + # only update end_date if history already had one + if history.end_date: + end_date = self.request.json_body['end_date'] + end_date = datetime.datetime.strptime(end_date, '%Y-%m-%d').date() + history.end_date = end_date + + self.Session.flush() + return self.profile_changed_response(person) + + def profile_update_employee_id(self): + """ + View to update an employee's ID value. + """ + app = self.get_rattail_app() + employment = app.get_employment_handler() + + person = self.get_instance() + employee = employment.get_employee(person) + + data = self.request.json_body + employee.id = data['employee_id'] + + 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 { + '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 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'] + note.text = form.validated['note_text'] + note.created_by = self.request.user + person.notes.append(note) + return note + + def profile_edit_note(self): + person = self.get_instance() + form = self.make_note_form('edit', person) + 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): + 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_delete_note(self): + person = self.get_instance() + form = self.make_note_form('delete', person) + 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): + model = self.model + note = self.Session.get(model.PersonNote, form.validated['uuid']) + self.Session.delete(note) + 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: @@ -202,6 +1681,38 @@ class PeopleView(MasterView): self.request.session.flash("User has been created: {}".format(user.username)) return self.redirect(self.request.route_url('users.view', uuid=user.uuid)) + def request_merge(self): + """ + Create a new merge request for the given 2 people. + """ + self.handler.request_merge(self.request.user, + self.request.POST['removing_uuid'], + 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) @@ -209,8 +1720,273 @@ class PeopleView(MasterView): @classmethod def _people_defaults(cls, config): + permission_prefix = cls.get_permission_prefix() 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() + model_title_plural = cls.get_model_title_plural() + + # "profile" perms + # TODO: should let view class (or config) determine which of these are available + config.add_tailbone_permission_group('people_profile', "People Profile View") + config.add_tailbone_permission('people_profile', 'people_profile.toggle_employee', + "Toggle the person's Employee status") + config.add_tailbone_permission('people_profile', 'people_profile.edit_employee_history', + "Edit the person's Employee History records") + + # view profile + config.add_tailbone_permission(permission_prefix, '{}.view_profile'.format(permission_prefix), + "View full \"profile\" for {}".format(model_title)) + config.add_route('{}.view_profile'.format(route_prefix), '{}/{{{}}}/profile'.format(url_prefix, model_key), + request_method='GET') + 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', + "Edit the Personal details") + + # profile - edit name + config.add_route('{}.profile_edit_name'.format(route_prefix), + '{}/profile/edit-name'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_edit_name', + route_name='{}.profile_edit_name'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - add phone + config.add_route('{}.profile_add_phone'.format(route_prefix), + '{}/profile/add-phone'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_add_phone', + route_name='{}.profile_add_phone'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - update phone + config.add_route('{}.profile_update_phone'.format(route_prefix), + '{}/profile/update-phone'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_update_phone', + route_name='{}.profile_update_phone'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - delete phone + config.add_route('{}.profile_delete_phone'.format(route_prefix), + '{}/profile/delete-phone'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_delete_phone', + route_name='{}.profile_delete_phone'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - set preferred phone + config.add_route('{}.profile_set_preferred_phone'.format(route_prefix), + '{}/profile/set-preferred-phone'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_set_preferred_phone', + route_name='{}.profile_set_preferred_phone'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - add email + config.add_route('{}.profile_add_email'.format(route_prefix), + '{}/profile/add-email'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_add_email', + route_name='{}.profile_add_email'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - update email + config.add_route('{}.profile_update_email'.format(route_prefix), + '{}/profile/update-email'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_update_email', + route_name='{}.profile_update_email'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - delete email + config.add_route('{}.profile_delete_email'.format(route_prefix), + '{}/profile/delete-email'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_delete_email', + route_name='{}.profile_delete_email'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - set preferred email + config.add_route('{}.profile_set_preferred_email'.format(route_prefix), + '{}/profile/set-preferred-email'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_set_preferred_email', + route_name='{}.profile_set_preferred_email'.format(route_prefix), + renderer='json', + permission='people_profile.edit_person') + + # profile - edit address + config.add_route('{}.profile_edit_address'.format(route_prefix), + '{}/profile/edit-address'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_edit_address', + route_name='{}.profile_edit_address'.format(route_prefix), + 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') + config.add_view(cls, attr='profile_start_employee', route_name='{}.profile_start_employee'.format(route_prefix), + permission='people_profile.toggle_employee', renderer='json') + + # profile - end employee + config.add_route('{}.profile_end_employee'.format(route_prefix), '{}/profile/end-employee'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_end_employee', route_name='{}.profile_end_employee'.format(route_prefix), + permission='people_profile.toggle_employee', renderer='json') + + # profile - edit employee history + config.add_route('{}.profile_edit_employee_history'.format(route_prefix), '{}/profile/edit-employee-history'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_edit_employee_history', route_name='{}.profile_edit_employee_history'.format(route_prefix), + permission='people_profile.edit_employee_history', renderer='json') + + # profile - update employee ID + config.add_route('{}.profile_update_employee_id'.format(route_prefix), + '{}/profile/update-employee-id'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='profile_update_employee_id', + route_name='{}.profile_update_employee_id'.format(route_prefix), + renderer='json', + permission='employees.edit') + + # 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') + + # 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') + + # 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') + + # 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), @@ -218,31 +1994,204 @@ class PeopleView(MasterView): config.add_view(cls, attr='make_user', route_name='{}.make_user'.format(route_prefix), permission='users.create') - -class PeopleAutocomplete(AutocompleteView): - - mapped_class = model.Person - fieldname = 'display_name' + # merge requests + if cls.mergeable: + config.add_tailbone_permission(permission_prefix, '{}.request_merge'.format(permission_prefix), + "Request merge for 2 {}".format(model_title_plural)) + config.add_route('{}.request_merge'.format(route_prefix), '{}/request-merge'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='request_merge', route_name='{}.request_merge'.format(route_prefix), + permission='{}.request_merge'.format(permission_prefix)) -class PeopleEmployeesAutocomplete(PeopleAutocomplete): +class PersonNoteView(MasterView): """ - Autocomplete view for the Person model, but restricted to return only - results for people who are employees. + Master view for the PersonNote class. """ + model_class = PersonNote + route_prefix = 'person_notes' + url_prefix = '/people/notes' + has_versions = True - def filter_query(self, q): - return q.join(model.Employee) + grid_columns = [ + 'person', + 'type', + 'subject', + 'created', + 'created_by', + ] + + form_fields = [ + 'person', + 'type', + 'subject', + 'text', + 'created', + 'created_by', + ] + + def get_instance_title(self, note): + return note.subject or "(no subject)" + + def configure_grid(self, g): + super().configure_grid(g) + model = self.model + + # person + g.set_joiner('person', lambda q: q.join(model.Person, + model.Person.uuid == model.PersonNote.parent_uuid)) + g.set_sorter('person', model.Person.display_name) + g.set_filter('person', model.Person.display_name, label="Person Name") + + # created_by + CreatorPerson = orm.aliased(model.Person) + g.set_joiner('created_by', lambda q: q.join(model.User).outerjoin(CreatorPerson, + CreatorPerson.uuid == model.User.person_uuid)) + g.set_sorter('created_by', CreatorPerson.display_name) + + g.set_sort_defaults('created', 'desc') + + g.set_link('person') + g.set_link('subject') + g.set_link('created') + + def configure_form(self, f): + super().configure_form(f) + + # person + f.set_readonly('person') + f.set_renderer('person', self.render_person) + + # created + f.set_readonly('created') + + # created_by + f.set_readonly('created_by') + f.set_renderer('created_by', self.render_user) + + +@colander.deferred +def valid_note_uuid(node, kw): + session = kw['session'] + person_uuid = kw['person_uuid'] + def validate(node, value): + note = session.get(PersonNote, value) + if not note: + raise colander.Invalid(node, "Note not found") + if note.person.uuid != person_uuid: + raise colander.Invalid(node, "Note is for the wrong person") + return note.uuid + return validate + + +class NoteSchema(colander.Schema): + + uuid = colander.SchemaNode(colander.String(), + validator=valid_note_uuid) + + note_type = colander.SchemaNode(colander.String()) + + note_subject = colander.SchemaNode(colander.String(), missing='') + + note_text = colander.SchemaNode(colander.String(), missing='') + + +class MergePeopleRequestView(MasterView): + """ + Master view for the MergePeopleRequest class. + """ + model_class = MergePeopleRequest + route_prefix = 'people_merge_requests' + url_prefix = '/people/merge-requests' + creatable = False + editable = False + + labels = { + 'removing_uuid': "Removing", + 'keeping_uuid': "Keeping", + } + + grid_columns = [ + 'removing_uuid', + 'keeping_uuid', + 'requested', + 'requested_by', + 'merged', + 'merged_by', + ] + + form_fields = [ + 'removing_uuid', + 'keeping_uuid', + 'requested', + 'requested_by', + 'merged', + 'merged_by', + ] + + def configure_grid(self, 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) + + g.filters['merged'].default_active = True + g.filters['merged'].default_verb = 'is_null' + + g.set_sort_defaults('requested', 'desc') + + g.set_link('removing_uuid') + 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.get(model.Person, uuid) + if person: + return str(person) + return "(person not found)" + + def get_instance_title(self, merge_request): + model = self.model + 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().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.get(model.Person, uuid) + if 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 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): - - # autocomplete - config.add_route('people.autocomplete', '/people/autocomplete') - config.add_view(PeopleAutocomplete, route_name='people.autocomplete', - renderer='json', permission='people.list') - config.add_route('people.autocomplete.employees', '/people/autocomplete/employees') - config.add_view(PeopleEmployeesAutocomplete, route_name='people.autocomplete.employees', - renderer='json', permission='people.list') - - PeopleView.defaults(config) + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.people') + else: + defaults(config) diff --git a/tailbone/grids/mobile.py b/tailbone/views/permissions.py similarity index 54% rename from tailbone/grids/mobile.py rename to tailbone/views/permissions.py index dc6a04b9..5168d544 100644 --- a/tailbone/grids/mobile.py +++ b/tailbone/views/permissions.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -21,33 +21,45 @@ # ################################################################################ """ -Mobile Grids +Raw Permission Views """ from __future__ import unicode_literals, absolute_import -from pyramid.renderers import render +from sqlalchemy import orm -from .core import Grid +from rattail.db import model + +from tailbone.views import MasterView -class MobileGrid(Grid): +class PermissionView(MasterView): """ - Base class for all mobile grids + Master view for the permissions model. """ + model_class = model.Permission + model_title = "Raw Permission" + editable = False + bulk_deletable = True - def render_filters(self, template='/mobile/grids/filters_simple.mako', **kwargs): - context = kwargs - context['request'] = self.request - context['grid'] = self - return render(template, context) + grid_columns = [ + 'role', + 'permission', + ] - def render_grid(self, template='/mobile/grids/grid.mako', **kwargs): - context = kwargs - context['grid'] = self - return render(template, context) + def query(self, session): + model = self.model + query = super(PermissionView, self).query(session) + query = query.options(orm.joinedload(model.Permission.role)) + return query - def render_complete(self, template='/mobile/grids/complete.mako', **kwargs): - context = kwargs - context['grid'] = self - return render(template, context) + +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 8fbe741c..3986f8b0 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.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,13 +24,15 @@ "Principal" master view """ -from __future__ import unicode_literals, absolute_import - import copy +from collections import OrderedDict -import wtforms +from rattail.core import Object -from tailbone.views import MasterView2 as MasterView +from webhelpers2.html import HTML + +from tailbone.db import Session +from tailbone.views import MasterView class PrincipalMasterView(MasterView): @@ -38,45 +40,116 @@ class PrincipalMasterView(MasterView): Master view base class for security principal models, i.e. User and Role. """ - def get_fallback_templates(self, template, mobile=False): + def get_fallback_templates(self, template, **kwargs): return [ '/principal/{}.mako'.format(template), - ] + super(PrincipalMasterView, self).get_fallback_templates(template, mobile=mobile) + ] + super().get_fallback_templates(template, **kwargs) + + def perm_sortkey(self, item): + key, value = item + return value['label'].lower() def find_by_perm(self): """ 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=lambda (k, v): v['label'].lower()) + sorted_perms = sorted(permissions.items(), key=self.perm_sortkey) for key, group in sorted_perms: - group['perms'] = sorted(group['perms'].items(), key=lambda (k, v): v['label'].lower()) - - # 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) + group['perms'] = sorted(group['perms'].items(), key=self.perm_sortkey) + # 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 = { + 'permissions': sorted_perms, + 'principals': principals, + 'principals_data': self.find_by_perm_results_data(principals), + 'grid': grid, + } + + 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 - context = {'form': form, 'permissions': sorted_perms, 'principals': principals} return self.render_to_response('find_by_perm', context) + def get_perms_data(self, sorted_perms): + data = OrderedDict() + for gkey, group in sorted_perms: + + gperms = [] + for pkey, perm in group['perms']: + gperms.append({ + 'permkey': pkey, + 'label': perm['label'], + }) + + data[gkey] = { + 'groupkey': gkey, + 'label': group['label'], + 'permissions': gperms, + } + + 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) @@ -90,8 +163,44 @@ 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)) + + +class PermissionsRenderer(Object): + permissions = None + include_guest = False + include_authenticated = False + + def __call__(self, principal, field): + self.principal = principal + 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()): + inner = HTML.tag('p', class_='group-label', c=self.permissions[groupkey]['label']) + perms = self.permissions[groupkey]['perms'] + rendered = False + for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): + 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 "[ ]") + inner += HTML.tag('p', class_='perm', c=[span, HTML(' '), label]) + rendered = True + if rendered: + html += HTML.tag('div', class_='permissions-group', c=[inner]) + return HTML.tag('div', class_='permissions-outer', c=[html or "(none granted)"]) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 807f2a8f..8461ae03 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.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,32 +24,32 @@ Product Views """ -from __future__ import unicode_literals, absolute_import - import re - -import six +import logging +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 -from rattail.batch import get_batch_handler +from rattail.util import simple_error -import wtforms -import formalchemy as fa -from pyramid import httpexceptions -from pyramid.renderers import render_to_response +import colander +from deform import widget as dfwidget from webhelpers2.html import tags, HTML from tailbone import forms, grids -from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView, AutocompleteView -from tailbone.progress import SessionProgress +from tailbone.views import MasterView +from tailbone.util import raw_datetime + + +log = logging.getLogger(__name__) # TODO: For a moment I thought this was going to be necessary, but now I think @@ -71,84 +71,133 @@ from tailbone.progress import SessionProgress # return query -class ProductsView(MasterView): +class ProductView(MasterView): """ Master view for the Product class. """ - model_class = model.Product - supports_mobile = True + 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', - 'subdepartment', + 'department', 'vendor', 'regular_price', 'current_price', ] - labels = { - 'status_code': "Status", - } - - # 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). - ProductCostAny = orm.aliased(model.ProductCost) - VendorAny = orm.aliased(model.Vendor) - - # same, but for prices - RegularPrice = orm.aliased(model.ProductPrice) - CurrentPrice = orm.aliased(model.ProductPrice) + form_fields = [ + '_product_key_', + 'brand', + 'description', + 'unit_size', + 'unit_of_measure', + 'size', + 'packs', + 'pack_size', + 'unit', + 'default_pack', + 'case_size', + 'weighed', + 'average_weight', + 'department', + 'subdepartment', + 'category', + 'family', + 'report_code', + 'suggested_price', + 'regular_price', + 'current_price', + 'current_price_ends', + 'sale_price', + 'sale_price_ends', + 'tpr_price', + 'tpr_price_ends', + 'vendor', + 'cost', + 'deposit_link', + 'tax', + 'tax1', + 'tax2', + 'tax3', + 'organic', + 'kosher', + 'vegan', + 'vegetarian', + 'gluten_free', + 'sugar_free', + 'discountable', + 'special_order', + 'not_for_sale', + 'ingredients', + 'notes', + 'status_code', + 'discontinued', + 'deleted', + 'last_sold', + 'inventory_on_hand', + 'inventory_on_order', + ] def __init__(self, request): - super(ProductsView, 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.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(ProductsView, self).configure_grid(g) - - def join_vendor(q): - return q.outerjoin(model.ProductCost, - sa.and_( - model.ProductCost.product_uuid == model.Product.uuid, - model.ProductCost.preference == 1))\ - .outerjoin(model.Vendor) - - def join_vendor_any(q): - return q.outerjoin(self.ProductCostAny, - self.ProductCostAny.product_uuid == model.Product.uuid)\ - .outerjoin(self.VendorAny, - self.VendorAny.uuid == self.ProductCostAny.vendor_uuid) + super().configure_grid(g) + app = self.get_rattail_app() + model = self.model ProductCostCode = orm.aliased(model.ProductCost) ProductCostCodeAny = orm.aliased(model.ProductCost) @@ -163,225 +212,1538 @@ class ProductsView(MasterView): return q.outerjoin(ProductCostCodeAny, ProductCostCodeAny.product_uuid == model.Product.uuid) - g.joiners['brand'] = lambda q: q.outerjoin(model.Brand) - g.joiners['family'] = lambda q: q.outerjoin(model.Family) - g.joiners['department'] = lambda q: q.outerjoin(model.Department, - model.Department.uuid == model.Product.department_uuid) - g.joiners['subdepartment'] = lambda q: q.outerjoin(model.Subdepartment, - model.Subdepartment.uuid == model.Product.subdepartment_uuid) - g.joiners['report_code'] = lambda q: q.outerjoin(model.ReportCode) + # 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) + 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') + + # 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.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 - g.sorters['brand'] = g.make_sorter(model.Brand.name) - g.sorters['department'] = g.make_sorter(model.Department.name) - g.sorters['subdepartment'] = g.make_sorter(model.Subdepartment.name) - g.sorters['vendor'] = g.make_sorter(model.Vendor.name) - g.set_sorter('on_hand', model.ProductInventory.on_hand) - g.set_sorter('on_order', model.ProductInventory.on_order) + # 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) + + # true_cost + g.set_joiner('true_cost', lambda q: q.outerjoin(ProductTrueCost)) + g.set_filter('true_cost', ProductTrueCost.true_cost) + g.set_sorter('true_cost', ProductTrueCost.true_cost) + g.set_renderer('true_cost', self.render_true_cost) + + # true_margin + g.set_joiner('true_margin', lambda q: q.outerjoin(ProductTrueMargin)) + g.set_filter('true_margin', ProductTrueMargin.true_margin) + g.set_sorter('true_margin', ProductTrueMargin.true_margin) + g.set_renderer('true_margin', self.render_true_margin) + + # 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 + 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['family'] = g.make_filter('family', model.Family.name) - g.filters['department'] = g.make_filter('department', model.Department.name, - default_active=True, default_verb='contains') - g.filters['subdepartment'] = g.make_filter('subdepartment', model.Subdepartment.name) - g.filters['report_code'] = g.make_filter('report_code', model.ReportCode.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['cost'] = lambda q: q.outerjoin(model.ProductCost, - sa.and_( - model.ProductCost.product_uuid == model.Product.uuid, - model.ProductCost.preference == 1)) - g.sorters['cost'] = g.make_sorter(model.ProductCost.unit_cost) - g.filters['cost'] = g.make_filter('cost', model.ProductCost.unit_cost) + # 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 + 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") - g.default_sortkey = 'upc' + # 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) - if self.print_labels and self.request.has_perm('products.print_labels'): - g.more_actions.append(grids.GridAction('print_label', icon='print')) + # 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) - g.set_type('upc', 'gpc') + # suggested_price + g.set_renderer('suggested_price', self.render_grid_suggested_price) + + # (unit) cost + g.set_joiner('cost', lambda q: q.outerjoin(model.ProductCost, + sa.and_( + model.ProductCost.product_uuid == model.Product.uuid, + model.ProductCost.preference == 1))) + g.set_sorter('cost', model.ProductCost.unit_cost) + g.set_filter('cost', model.ProductCost.unit_cost) + g.set_renderer('cost', self.render_cost) + g.set_label('cost', "Unit Cost") + + # tax + g.set_joiner('tax', lambda q: q.outerjoin(model.Tax)) + taxes = self.Session.query(model.Tax)\ + .order_by(model.Tax.code)\ + .all() + taxes = OrderedDict([(tax.uuid, tax.description) + for tax in taxes]) + g.set_filter('tax', model.Tax.uuid, value_enum=taxes) + + # report_code_name + g.set_joiner('report_code_name', lambda q: q.outerjoin(model.ReportCode)) + g.set_filter('report_code_name', model.ReportCode.name) + + if self.expose_label_printing and self.has_perm('print_labels'): + 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('current_price', self.render_price) - g.set_renderer('cost', self.render_cost) 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('upc', "UPC") - g.set_label('vendor', "Vendor (preferred)") - g.set_label('vendor_any', "Vendor (any)") - g.set_label('vendor', "Pref. Vendor") - - def render_price(self, product, column): - price = product[column] - if price: - if not product.not_for_sale: - if price.price is not None and price.pack_price is not None: - if price.multiple > 1: - return HTML("$ {:0.2f} / {} ($ {:0.2f} / {})".format( - price.price, price.multiple, - price.pack_price, price.pack_multiple)) - return HTML("$ {:0.2f} ($ {:0.2f} / {})".format( - price.price, price.pack_price, price.pack_multiple)) - if price.price is not None: - if price.multiple > 1: - return "$ {:0.2f} / {}".format(price.price, price.multiple) - return "$ {:0.2f}".format(price.price) - if price.pack_price is not None: - return "$ {:0.2f} / {}".format(price.pack_price, price.pack_multiple) - return "" - - def render_cost(self, product, column): - cost = product.cost + 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): + # 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 "" - return pretty_quantity(inventory.on_hand) + 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 "" - return pretty_quantity(inventory.on_order) + app = self.get_rattail_app() + return app.render_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 + 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 = Session.query(model.Product).get(key) + product = self.Session.get(model.Product, key) if product: return product - price = Session.query(model.ProductPrice).get(key) + price = self.Session.get(model.ProductPrice, key) if price: return price.product - raise httpexceptions.HTTPNotFound() + raise self.notfound() - def _preconfigure_fieldset(self, fs): - fs.upc.set(label="UPC", renderer=forms.renderers.GPCFieldRenderer) - fs.brand.set(renderer=forms.renderers.BrandFieldRenderer, options=[]) - fs.department.set(renderer=forms.renderers.DepartmentFieldRenderer) - fs.subdepartment.set(renderer=forms.renderers.SubdepartmentFieldRenderer) - fs.category.set(renderer=forms.renderers.CategoryFieldRenderer) - fs.unit_size.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.unit_of_measure.set(label="Unit of Measure", - renderer=forms.renderers.EnumFieldRenderer(self.enum.UNIT_OF_MEASURE)) - fs.unit.set(renderer=forms.renderers.ProductFieldRenderer, label="Unit Item") - fs.pack_size.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.regular_price.set(renderer=forms.renderers.PriceFieldRenderer, readonly=True) - fs.current_price.set(renderer=forms.renderers.PriceFieldRenderer, readonly=True) - fs.last_sold.set(readonly=True) - fs.status_code.set(label="Status") - fs.notes.set(renderer=fa.TextAreaFieldRenderer, size=(80, 10)) - fs.append(fa.Field('current_price_ends', type=fa.types.DateTime, readonly=True, - value=lambda p: p.current_price.ends if p.current_price else None)) - fs.append(fa.Field('inventory_on_hand', readonly=True, label="On Hand", - value=lambda p: p.inventory.on_hand if p.inventory else None)) - fs.append(fa.Field('inventory_on_order', readonly=True, label="On Order", - value=lambda p: p.inventory.on_order if p.inventory else None)) + def configure_form(self, f): + super().configure_form(f) + model = self.model + product = f.model_instance + + # 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 + if self.creating: + f.remove_field('packs') + elif self.viewing and product.packs: + f.set_renderer('packs', self.render_packs) + f.set_label('packs', "Pack Items") + else: + f.remove_field('packs') + + # pack_size + if self.viewing and not product.is_pack_item(): + f.remove_field('pack_size') + else: + f.set_type('pack_size', 'quantity') + + # default_pack + if self.viewing and not product.is_pack_item(): + f.remove_field('default_pack') + + # unit + if self.creating: + f.remove_field('unit') + elif self.viewing and product.is_pack_item(): + f.set_renderer('unit', self.render_unit) + f.set_label('unit', "Unit Item") + else: + f.remove_field('unit') + + # suggested_price + if self.creating: + f.remove_field('suggested_price') + else: + f.set_readonly('suggested_price') + f.set_renderer('suggested_price', self.render_suggested_price) + + # regular_price + if self.creating: + f.remove_field('regular_price') + else: + f.set_readonly('regular_price') + f.set_renderer('regular_price', self.render_regular_price) + + # current_price + if self.creating: + f.remove_field('current_price') + else: + f.set_readonly('current_price') + f.set_renderer('current_price', self.render_current_price) + + # current_price_ends + if self.creating: + f.remove_field('current_price_ends') + else: + 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') + else: + f.set_readonly('vendor') + f.set_label('vendor', "Preferred Vendor") + + # cost + if self.creating: + f.remove_field('cost') + else: + f.set_readonly('cost') + f.set_label('cost', "Preferred Unit Cost") + f.set_renderer('cost', self.render_cost) + + # last_sold + if self.creating: + f.remove_field('last_sold') + else: + f.set_readonly('last_sold') + + # inventory_on_hand + if self.creating: + f.remove_field('inventory_on_hand') + else: + f.set_readonly('inventory_on_hand') + f.set_renderer('inventory_on_hand', self.render_inventory_on_hand) + f.set_label('inventory_on_hand', "On Hand") + + # inventory_on_order + if self.creating: + f.remove_field('inventory_on_order') + else: + f.set_readonly('inventory_on_order') + f.set_renderer('inventory_on_order', self.render_inventory_on_order) + f.set_label('inventory_on_order', "On Order") + + # 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) + + # subdepartment + if self.creating or self.editing: + if 'subdepartment' in f.fields: + f.replace('subdepartment', 'subdepartment_uuid') + subdepartments = self.Session.query(model.Subdepartment)\ + .order_by(model.Subdepartment.number) + subdept_values = [(s.uuid, "{} {}".format(s.number, s.name)) + for s in subdepartments] + require_subdepartment = False + if not require_subdepartment: + subdept_values.insert(0, ('', "(none)")) + f.set_widget('subdepartment_uuid', dfwidget.SelectWidget(values=subdept_values)) + f.set_label('subdepartment_uuid', "Subdepartment") + else: + f.set_readonly('subdepartment') + f.set_renderer('subdepartment', self.render_subdepartment) + + # category + if self.creating or self.editing: + if 'category' in f.fields: + f.replace('category', 'category_uuid') + categories = self.Session.query(model.Category)\ + .order_by(model.Category.code) + category_values = [(c.uuid, "{} {}".format(c.code, c.name)) + for c in categories] + require_category = False + if not require_category: + category_values.insert(0, ('', "(none)")) + f.set_widget('category_uuid', dfwidget.SelectWidget(values=category_values)) + f.set_label('category_uuid', "Category") + else: + f.set_readonly('category') + f.set_renderer('category', self.render_category) + + # family + if self.creating or self.editing: + if 'family' in f.fields: + f.replace('family', 'family_uuid') + families = self.Session.query(model.Family)\ + .order_by(model.Family.name) + family_values = [(fam.uuid, fam.name) for fam in families] + require_family = False + if not require_family: + family_values.insert(0, ('', "(none)")) + f.set_widget('family_uuid', dfwidget.SelectWidget(values=family_values)) + f.set_label('family_uuid', "Family") + else: + f.set_readonly('family') + f.set_renderer('family', self.render_family) + + # report_code + if self.creating or self.editing: + if 'report_code' in f.fields: + f.replace('report_code', 'report_code_uuid') + report_codes = self.Session.query(model.ReportCode)\ + .order_by(model.ReportCode.code) + report_code_values = [(rc.uuid, "{} {}".format(rc.code, rc.name)) + for rc in report_codes] + require_report_code = False + if not require_report_code: + report_code_values.insert(0, ('', "(none)")) + f.set_widget('report_code_uuid', dfwidget.SelectWidget(values=report_code_values)) + f.set_label('report_code_uuid', "Report Code") + else: + f.set_readonly('report_code') + # f.set_renderer('report_code', self.render_report_code) + + # regular_price_amount + if self.editing: + f.set_node('regular_price_amount', colander.Decimal()) + f.set_default('regular_price_amount', product.regular_price.price if product.regular_price else None) + f.set_label('regular_price_amount', "Regular Price") + + # deposit_link + if self.creating or self.editing: + if 'deposit_link' in f.fields: + f.replace('deposit_link', 'deposit_link_uuid') + deposit_links = self.Session.query(model.DepositLink)\ + .order_by(model.DepositLink.code) + deposit_link_values = [(dl.uuid, "{} {}".format(dl.code, dl.description)) + for dl in deposit_links] + require_deposit_link = False + if not require_deposit_link: + deposit_link_values.insert(0, ('', "(none)")) + f.set_widget('deposit_link_uuid', dfwidget.SelectWidget(values=deposit_link_values)) + f.set_label('deposit_link_uuid', "Deposit Link") + else: + f.set_readonly('deposit_link') + # f.set_renderer('deposit_link', self.render_deposit_link) + + # tax + if self.creating or self.editing: + if 'tax' in f.fields: + f.replace('tax', 'tax_uuid') + taxes = self.Session.query(model.Tax)\ + .order_by(model.Tax.code) + tax_values = [(tax.uuid, "{} {}".format(tax.code, tax.description)) + for tax in taxes] + require_tax = False + if not require_tax: + tax_values.insert(0, ('', "(none)")) + f.set_widget('tax_uuid', dfwidget.SelectWidget(values=tax_values)) + f.set_label('tax_uuid', "Tax") + else: + f.set_readonly('tax') + f.set_renderer('tax', self.render_tax) + + # tax1/2/3 + f.set_readonly('tax1') + f.set_readonly('tax2') + f.set_readonly('tax3') + + # brand + if self.creating or self.editing: + if 'brand' in f.fields: + f.replace('brand', 'brand_uuid') + 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(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)) + f.set_label('brand_uuid', "Brand") + else: + f.set_readonly('brand') + + # case_size + f.set_type('case_size', 'quantity') + + # status_code + f.set_label('status_code', "Status") + + # ingredients + f.set_widget('ingredients', dfwidget.TextAreaWidget(cols=80, rows=10)) + + # notes + f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=10)) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.upc, - fs.brand, - fs.description, - fs.unit_size, - fs.unit_of_measure, - fs.size, - fs.unit, - fs.pack_size, - fs.case_size, - fs.weighed, - fs.department, - fs.subdepartment, - fs.category, - fs.family, - fs.report_code, - fs.regular_price, - fs.current_price, - fs.current_price_ends, - fs.deposit_link, - fs.tax, - fs.organic, - fs.kosher, - fs.vegan, - fs.vegetarian, - fs.gluten_free, - fs.sugar_free, - fs.discountable, - fs.special_order, - fs.not_for_sale, - fs.ingredients, - fs.notes, - fs.status_code, - fs.discontinued, - fs.deleted, - fs.last_sold, - ]) if not self.request.has_perm('products.view_deleted'): - del fs.deleted + f.remove('deleted') + + def objectify(self, form, data=None): + if data is None: + data = form.validated + product = super().objectify(form, data=data) + + # regular_price_amount + if (self.creating or self.editing) and 'regular_price_amount' in form.fields: + api.set_regular_price(product, data['regular_price_amount']) + + 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: + return "" + if department.number: + text = '({}) {}'.format(department.number, department.name) + else: + text = department.name + url = self.request.route_url('departments.view', uuid=department.uuid) + return tags.link_to(text, url) + + def render_subdepartment(self, product, field): + subdepartment = product.subdepartment + if not subdepartment: + return "" + if subdepartment.number: + text = '({}) {}'.format(subdepartment.number, subdepartment.name) + else: + text = subdepartment.name + url = self.request.route_url('subdepartments.view', uuid=subdepartment.uuid) + return tags.link_to(text, url) + + def render_category(self, product, field): + category = product.category + if not category: + return "" + if category.code: + text = '({}) {}'.format(category.code, category.name) + elif category.number: + text = '({}) {}'.format(category.number, category.name) + else: + text = category.name + url = self.request.route_url('categories.view', uuid=category.uuid) + return tags.link_to(text, url) + + def render_packs(self, product, field): + if product.is_pack_item(): + return "" + + links = [] + for pack in product.packs: + if pack.upc: + code = pack.upc.pretty() + elif pack.scancode: + code = pack.scancode + else: + code = pack.item_id + text = "({}) {}".format(code, pack.full_description) + url = self.get_action_url('view', pack) + links.append(tags.link_to(text, url)) + + items = [HTML.tag('li', c=[link]) for link in links] + return HTML.tag('ul', c=items) + + def render_unit(self, product, field): + unit = product.unit + if not unit: + return "" + + if unit.upc: + code = unit.upc.pretty() + elif unit.scancode: + code = unit.scancode + else: + code = unit.item_id + + text = "({}) {}".format(code, unit.full_description) + url = self.get_action_url('view', unit) + return tags.link_to(text, url) + + def render_current_price_ends(self, product, field): + if not product.current_price: + return "" + value = product.current_price.ends + if not value: + 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 "" + app = self.get_rattail_app() + return app.render_quantity(value) + + def render_inventory_on_order(self, product, field): + if not product.inventory: + return "" + value = product.inventory.on_order + if not value: + return "" + 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') + assert typ in ('regular', 'current', 'suggested') + + getter = getattr(self, 'get_{}_price_history'.format(typ)) + data = getter(product) + + # make some data JSON-friendly + jsdata = [] + for history in data: + history = dict(history) + price = history['price'] + 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'] = str(user or "??") + jsdata.append(history) + return jsdata + + def cost_history(self): + """ + AJAX view for fetching cost history for a product. + """ + app = self.get_rattail_app() + product = self.get_instance() + data = self.get_cost_history(product) + + # make some data JSON-friendly + jsdata = [] + for history in data: + history = dict(history) + cost = history['cost'] + if cost is not None: + history['cost'] = float(cost) + history['cost_display'] = "${:0.2f}".format(cost) + else: + history['cost_display'] = None + 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'] = str(user) + jsdata.append(history) + return jsdata def template_kwargs_view(self, **kwargs): - kwargs['image'] = False + kwargs = super().template_kwargs_view(**kwargs) product = kwargs['instance'] - if product.upc: - kwargs['image_url'] = pod.get_image_url(self.rattail_config, product.upc) - kwargs['image_path'] = pod.get_image_path(self.rattail_config, product.upc) + + 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 + data = [] # defer fetching until user asks for it + grid = grids.Grid(self.request, + key='products.regular_price_history', + data=data, + columns=[ + 'price', + 'since', + 'changed', + 'changed_by', + ]) + grid.set_type('price', 'currency') + grid.set_type('changed', 'datetime') + kwargs['regular_price_history_grid'] = grid + + # current price + data = [] # defer fetching until user asks for it + grid = grids.Grid(self.request, + key='products.current_price_history', + data=data, + columns=[ + 'price', + 'price_type', + 'since', + 'changed', + 'changed_by', + ], + labels={ + 'price_type': "Type", + }) + grid.set_type('price', 'currency') + grid.set_type('changed', 'datetime') + kwargs['current_price_history_grid'] = grid + + # suggested price + data = [] # defer fetching until user asks for it + grid = grids.Grid(self.request, + key='products.suggested_price_history', + data=data, + columns=[ + 'price', + 'since', + 'changed', + 'changed_by', + ]) + grid.set_type('price', 'currency') + grid.set_type('changed', 'datetime') + kwargs['suggested_price_history_grid'] = grid + + # cost history + data = [] # defer fetching until user asks for it + grid = grids.Grid(self.request, + key='products.cost_history', + data=data, + columns=[ + 'cost', + 'vendor', + 'since', + 'changed', + 'changed_by', + ], + labels={ + 'price_type': "Type", + }) + grid.set_type('cost', 'currency') + grid.set_type('changed', 'datetime') + kwargs['cost_history_grid'] = grid + + kwargs['costs_label_preferred'] = "Pref." + kwargs['costs_label_vendor'] = "Vendor" + kwargs['costs_label_code'] = "Order Code" + kwargs['costs_label_case_size'] = "Case Size" + + 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( + self.request, + key=f'{route_prefix}.vendor_sources', + data=[], + columns=columns, + labels={ + 'preferred': "Pref.", + 'vendor_item_code': "Order Code", + }, + ) + + sources = [] + link_vendor = self.request.has_perm('vendors.view') + for cost in product.costs: + + source = { + 'uuid': cost.uuid, + 'preferred': "X" if cost.preference == 1 else None, + 'vendor_item_code': cost.code, + 'unit_cost': app.render_currency(cost.unit_cost, scale=4), + 'status': "discontinued" if cost.discontinued else "available", + } + + 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) + else: + source['vendor'] = text + + sources.append(source) + + return {'grid': g, 'data': sources} + + def get_context_lookup_codes(self, product): + route_prefix = self.get_route_prefix() + + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.lookup_codes', + data=[], + columns=[ + 'sequence', + 'code', + ], + labels={ + 'sequence': "Seq.", + }, + ) + + lookup_codes = [] + for code in product._codes: + + lookup_codes.append({ + 'uuid': code.uuid, + 'sequence': code.ordinal, + 'code': code.code, + }) + + return {'grid': g, 'data': lookup_codes} + + def get_regular_price_history(self, product): + """ + 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 = app.make_utc() + history = [] + + # first we find all relevant ProductVersion records + versions = self.Session.query(ProductVersion)\ + .join(Transaction, + Transaction.id == ProductVersion.transaction_id)\ + .filter(ProductVersion.uuid == product.uuid)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_uuid = None + for version in versions: + if version.regular_price_uuid != last_uuid: + changed = version.transaction.issued_at + if version.regular_price: + assert isinstance(version.regular_price, ProductPriceVersion) + price = version.regular_price.price + else: + price = None + history.append({ + 'transaction_id': version.transaction.id, + 'price': price, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_uuid = version.regular_price_uuid + + # next we find all relevant ProductPriceVersion records + versions = self.Session.query(ProductPriceVersion)\ + .join(Transaction, + Transaction.id == ProductPriceVersion.transaction_id)\ + .filter(ProductPriceVersion.product_uuid == product.uuid)\ + .filter(ProductPriceVersion.type == self.enum.PRICE_TYPE_REGULAR)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_price = None + for version in versions: + if version.price != last_price: + changed = version.transaction.issued_at + price = version.price + history.append({ + 'transaction_id': version.transaction.id, + 'price': version.price, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_price = version.price + + final_history = OrderedDict() + for hist in sorted(history, key=lambda h: h['changed'], reverse=True): + if hist['transaction_id'] not in final_history: + final_history[hist['transaction_id']] = hist + + return list(final_history.values()) + + def get_current_price_history(self, product): + """ + 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 = app.make_utc() + history = [] + + # first we find all relevant ProductVersion records + versions = self.Session.query(ProductVersion)\ + .join(Transaction, + Transaction.id == ProductVersion.transaction_id)\ + .filter(ProductVersion.uuid == product.uuid)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_current_uuid = None + last_regular_uuid = None + for version in versions: + + changed = False + if version.current_price_uuid != last_current_uuid: + changed = True + elif not version.current_price_uuid and version.regular_price_uuid != last_regular_uuid: + changed = True + + if changed: + changed = version.transaction.issued_at + if version.current_price: + assert isinstance(version.current_price, ProductPriceVersion) + price = version.current_price.price + price_type = self.enum.PRICE_TYPE.get(version.current_price.type) + elif version.regular_price: + price = version.regular_price.price + price_type = self.enum.PRICE_TYPE.get(version.regular_price.type) + else: + price = None + price_type = None + history.append({ + 'transaction_id': version.transaction.id, + 'price': price, + 'price_type': price_type, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + + last_current_uuid = version.current_price_uuid + last_regular_uuid = version.regular_price_uuid + + # next we find all relevant *SALE* ProductPriceVersion records + versions = self.Session.query(ProductPriceVersion)\ + .join(Transaction, + Transaction.id == ProductPriceVersion.transaction_id)\ + .filter(ProductPriceVersion.product_uuid == product.uuid)\ + .filter(ProductPriceVersion.type == self.enum.PRICE_TYPE_SALE)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_price = None + for version in versions: + # only include this version if it was "current" at the time + if version.uuid == version.product.current_price_uuid: + if version.price != last_price: + changed = version.transaction.issued_at + price = version.price + history.append({ + 'transaction_id': version.transaction.id, + 'price': version.price, + 'price_type': self.enum.PRICE_TYPE[version.type], + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_price = version.price + + # next we find all relevant *TPR* ProductPriceVersion records + versions = self.Session.query(ProductPriceVersion)\ + .join(Transaction, + Transaction.id == ProductPriceVersion.transaction_id)\ + .filter(ProductPriceVersion.product_uuid == product.uuid)\ + .filter(ProductPriceVersion.type == self.enum.PRICE_TYPE_TPR)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_price = None + for version in versions: + # only include this version if it was "current" at the time + if version.uuid == version.product.current_price_uuid: + if version.price != last_price: + changed = version.transaction.issued_at + price = version.price + history.append({ + 'transaction_id': version.transaction.id, + 'price': version.price, + 'price_type': self.enum.PRICE_TYPE[version.type], + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_price = version.price + + # next we find all relevant *Regular* ProductPriceVersion records + versions = self.Session.query(ProductPriceVersion)\ + .join(Transaction, + Transaction.id == ProductPriceVersion.transaction_id)\ + .filter(ProductPriceVersion.product_uuid == product.uuid)\ + .filter(ProductPriceVersion.type == self.enum.PRICE_TYPE_REGULAR)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_price = None + for version in versions: + # only include this version if it was "regular" at the time + if version.uuid == version.product.regular_price_uuid: + if version.price != last_price: + changed = version.transaction.issued_at + price = version.price + history.append({ + 'transaction_id': version.transaction.id, + 'price': version.price, + 'price_type': self.enum.PRICE_TYPE[version.type], + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_price = version.price + + final_history = OrderedDict() + for hist in sorted(history, key=lambda h: h['changed'], reverse=True): + if hist['transaction_id'] not in final_history: + final_history[hist['transaction_id']] = hist + + return list(final_history.values()) + + def get_suggested_price_history(self, product): + """ + 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 = app.make_utc() + history = [] + + # first we find all relevant ProductVersion records + versions = self.Session.query(ProductVersion)\ + .join(Transaction, + Transaction.id == ProductVersion.transaction_id)\ + .filter(ProductVersion.uuid == product.uuid)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_uuid = None + for version in versions: + if version.suggested_price_uuid != last_uuid: + changed = version.transaction.issued_at + if version.suggested_price: + assert isinstance(version.suggested_price, ProductPriceVersion) + price = version.suggested_price.price + else: + price = None + history.append({ + 'transaction_id': version.transaction.id, + 'price': price, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_uuid = version.suggested_price_uuid + + # next we find all relevant ProductPriceVersion records + versions = self.Session.query(ProductPriceVersion)\ + .join(Transaction, + Transaction.id == ProductPriceVersion.transaction_id)\ + .filter(ProductPriceVersion.product_uuid == product.uuid)\ + .filter(ProductPriceVersion.type == self.enum.PRICE_TYPE_MFR_SUGGESTED)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_price = None + for version in versions: + if version.price != last_price: + changed = version.transaction.issued_at + price = version.price + history.append({ + 'transaction_id': version.transaction.id, + 'price': version.price, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + last_price = version.price + + final_history = OrderedDict() + for hist in sorted(history, key=lambda h: h['changed'], reverse=True): + if hist['transaction_id'] not in final_history: + final_history[hist['transaction_id']] = hist + + return list(final_history.values()) + + def get_cost_history(self, product): + """ + 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 = app.make_utc() + history = [] + + # we just find all relevant (preferred!) ProductCostVersion records + versions = self.Session.query(ProductCostVersion)\ + .join(Transaction, + Transaction.id == ProductCostVersion.transaction_id)\ + .filter(ProductCostVersion.product_uuid == product.uuid)\ + .filter(ProductCostVersion.preference == 1)\ + .order_by(Transaction.issued_at, + Transaction.id)\ + .all() + + last_cost = None + last_vendor_uuid = None + for version in versions: + + changed = False + if version.unit_cost != last_cost: + changed = True + elif version.vendor_uuid != last_vendor_uuid: + changed = True + + if changed: + changed = version.transaction.issued_at + history.append({ + 'transaction_id': version.transaction.id, + 'cost': version.unit_cost, + 'vendor': version.vendor.name, + 'since': humanize.naturaltime(now - changed), + 'changed': changed, + 'changed_by': version.transaction.user, + }) + + last_cost = version.unit_cost + last_vendor_uuid = version.vendor_uuid + + final_history = OrderedDict() + for hist in sorted(history, key=lambda h: h['changed'], reverse=True): + if hist['transaction_id'] not in final_history: + final_history[hist['transaction_id']] = hist + + return list(final_history.values()) + def edit(self): # TODO: Should add some more/better hooks, so don't have to duplicate # so much code here. @@ -390,9 +1752,8 @@ class ProductsView(MasterView): form = self.make_form(instance) product_deleted = instance.deleted if self.request.method == 'POST': - if form.validate(): - self.save_form(form) - self.after_edit(instance) + if self.validate_form(form): + self.save_edit_form(form) self.request.session.flash("{} {} has been updated.".format( self.get_model_title(), self.get_instance_title(instance))) return self.redirect(self.get_action_url('view', instance)) @@ -403,6 +1764,7 @@ class ProductsView(MasterView): 'form': form}) def get_version_child_classes(self): + model = self.model return [ (model.ProductCode, 'product_uuid'), (model.ProductCost, 'product_uuid'), @@ -415,40 +1777,176 @@ class ProductsView(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': unicode(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) @@ -463,93 +1961,231 @@ class ProductsView(MasterView): return {'product': data} def get_supported_batches(self): - return { - 'labels': 'rattail.batch.labels:LabelBatchHandler', - 'pricing': 'rattail.batch.pricing:PricingBatchHandler', - } + 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': pricing.get_spec(), + }), + ('delproduct', { + 'spec': self.rattail_config.get('rattail.batch', 'delproduct.handler', + default='rattail.batch.delproduct:DeleteProductBatchHandler'), + }), + ]) def make_batch(self): """ 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, spec in list(supported.items()): - handler = load_object(spec)(self.rattail_config) - handler.spec = spec + for key, info in list(supported.items()): + 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()) supported[key] = handler - batch_options.append((key, handler.get_model_title())) + batch_options.append((key, handler.option_title)) - class MakeBatchForm(wtforms.Form): - batch_type = wtforms.SelectField(choices=batch_options) + schema = colander.SchemaNode( + colander.Mapping(), + colander.SchemaNode(colander.String(), name='batch_type', widget=dfwidget.SelectWidget(values=batch_options)), + colander.SchemaNode(colander.String(), name='description', missing=colander.null), + colander.SchemaNode(colander.String(), name='notes', missing=colander.null), + ) + + form = forms.Form(schema=schema, request=self.request, + cancel_url=self.get_index_url()) + form.auto_disable_save = True + form.submit_label = "Create Batch" + form.set_type('notes', 'text') - form = MakeBatchForm(self.request.POST) params_forms = {} for key, handler in supported.items(): - make_form = getattr(self, 'make_batch_params_form_{}'.format(key), None) - if make_form: - params_forms[key] = make_form() + make_schema = getattr(self, 'make_batch_params_schema_{}'.format(key), None) + if make_schema: + schema = make_schema() + # must prefix node names with batch key, to guarantee unique + for node in schema: + node.param_name = node.name + node.name = '{}_{}'.format(key, node.name) + params_forms[key] = forms.Form(schema=schema, request=self.request) - if self.request.method == 'POST' and form.validate(): - batch_key = form.batch_type.data - params = {} - pform = params_forms.get(batch_key) - if not pform or pform.validate(): # params form must validate if present + if self.request.method == 'POST': + if form.validate(): + data = form.validated + fully_validated = True + + # collect general params + batch_key = data['batch_type'] + params = { + 'description': data['description'], + 'notes': data['notes']} + + # collect batch-type-specific params + pform = params_forms.get(batch_key) if pform: - params = dict((name, pform[name].data) for name in pform._fields) - handler = get_batch_handler(self.rattail_config, batch_key, - default=supported[batch_key].spec) - products = self.get_effective_data() - progress = SessionProgress(self.request, 'products.batch') - thread = Thread(target=self.make_batch_thread, - args=(handler, self.request.user.uuid, products, params, progress)) - thread.start() - return self.render_progress(progress, { - 'cancel_url': self.get_index_url(), - 'cancel_msg': "Batch creation was canceled.", - }) - - return {'form': form, 'params_forms': params_forms} + if pform.validate(): + pdata = pform.validated + for field in pform.schema: + param_name = pform.schema[field.name].param_name + params[param_name] = pdata[field.name] + else: + fully_validated = False - def make_batch_params_form_pricing(self): - """ - Returns a wtforms.Form object with param fields for making a new - Pricing Batch. - """ - class PricingParamsForm(wtforms.Form): - description = wtforms.StringField() - min_diff_threshold = wtforms.DecimalField(places=2, validators=[wtforms.validators.optional()]) - notes = wtforms.TextAreaField() + if fully_validated: - return PricingParamsForm(self.request.POST) + # TODO: should this be done elsewhere? + for name in params: + if params[name] is colander.null: + params[name] = None + + handler = supported[batch_key] + products = self.get_products_for_batch(batch_key) + progress = self.make_progress('products.batch') + thread = Thread(target=self.make_batch_thread, + args=(handler, self.request.user.uuid, products, params, progress)) + thread.start() + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), + 'cancel_msg': "Batch creation was canceled.", + }) + + return self.render_to_response('batch', { + 'form': form, + 'dform': form.make_deform_form(), # TODO: hacky? at least is explicit.. + 'params_forms': params_forms, + }) + + def get_products_for_batch(self, batch_key): + """ + Returns the products query to be used when making a batch (of type + ``batch_key``) with the user's current filters in effect. You can + override this to add eager joins for certain batch types, etc. + """ + return self.get_effective_data() + + def make_batch_params_schema_pricing(self): + """ + Return params schema for making a pricing batch. + """ + app = self.get_rattail_app() + + schema = colander.SchemaNode( + colander.Mapping(), + colander.SchemaNode(colander.Decimal(), name='min_diff_threshold', + quant='1.00', missing=colander.null, + title="Min $ Diff"), + colander.SchemaNode(colander.Decimal(), name='min_diff_percent', + quant='1.00', missing=colander.null, + title="Min % Diff"), + 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. + """ + return colander.SchemaNode( + colander.Mapping(), + colander.SchemaNode(colander.Integer(), name='inactivity_months', + # TODO: probably should be configurable + default=18), + ) def make_batch_thread(self, handler, user_uuid, products, params, progress): """ 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 - batch = handler.make_batch(session, **params) - batch.products = products.with_session(session).all() - handler.populate(batch, progress=progress) + try: + batch = handler.make_batch(session, **params) + batch.products = products.with_session(session).all() + handler.do_populate(batch, user, progress=progress) - session.commit() - session.refresh(batch) - session.close() + except Exception as error: + session.rollback() + log.exception("failed to make '%s' batch with params: %s", + handler.batch_key, params) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Failed to make '{}' batch: {}".format( + handler.batch_key, simple_error(error)) + progress.session.save() - progress.session.load() - progress.session['complete'] = True - progress.session['success_url'] = self.get_batch_view_url(batch) - progress.session['success_msg'] = 'Batch has been created: {}'.format(batch) - progress.session.save() + else: + session.commit() + session.refresh(batch) + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_batch_view_url(batch) + progress.session['success_msg'] = 'Batch has been created: {}'.format(batch) + progress.session.save() def get_batch_view_url(self, batch): if batch.batch_key == 'labels': return self.request.route_url('labels.batch.view', uuid=batch.uuid) if batch.batch_key == 'pricing': return self.request.route_url('batch.pricing.view', uuid=batch.uuid) + 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): @@ -558,15 +2194,25 @@ class ProductsView(MasterView): @classmethod def _product_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() 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', @@ -580,73 +2226,533 @@ class ProductsView(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') config.add_view(cls, attr='image', route_name='products.image') + # price history + config.add_route('{}.price_history'.format(route_prefix), '{}/price-history'.format(instance_url_prefix), + request_method='GET') + config.add_view(cls, attr='price_history', route_name='{}.price_history'.format(route_prefix), + renderer='json', + permission='{}.versions'.format(permission_prefix)) -class ProductsAutocomplete(AutocompleteView): + # cost history + config.add_route('{}.cost_history'.format(route_prefix), '{}/cost-history'.format(instance_url_prefix), + request_method='GET') + config.add_view(cls, attr='cost_history', route_name='{}.cost_history'.format(route_prefix), + renderer='json', + permission='{}.versions'.format(permission_prefix)) + + +class PendingProductView(MasterView): """ - Autocomplete view for products. + Master view for the Pending Product class. """ - mapped_class = model.Product - fieldname = 'description' + model_class = PendingProduct + route_prefix = 'pending_products' + url_prefix = '/products/pending' + bulk_deletable = True - def query(self, term): - q = Session.query(model.Product).outerjoin(model.Brand) - q = q.filter(sa.or_( - model.Brand.name.ilike('%{}%'.format(term)), - model.Product.description.ilike('%{}%'.format(term)))) - if not self.request.has_perm('products.view_deleted'): - q = q.filter(model.Product.deleted == False) - q = q.order_by(model.Brand.name, model.Product.description) - q = q.options(orm.joinedload(model.Product.brand)) - return q + labels = { + 'regular_price_amount': "Regular Price", + 'status_code': "Status", + 'user': "Created by", + } - def display(self, product): - return product.full_description + grid_columns = [ + '_product_key_', + 'brand_name', + 'description', + 'size', + 'department_name', + 'created', + 'user', + 'status_code', + ] + + 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', + ] + + 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') -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 ProductCostView(MasterView): + """ + Master view for Product Costs + """ + model_class = ProductCost + route_prefix = 'product_costs' + url_prefix = '/products/costs' + has_versions = 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"} + grid_columns = [ + '_product_key_', + 'vendor', + 'preference', + 'code', + 'case_size', + 'case_cost', + 'pack_size', + 'pack_cost', + 'unit_cost', + ] - quantity = request.params.get('quantity') - if not quantity.isdigit(): - return {'error': "Quantity must be numeric"} - quantity = int(quantity) + def query(self, session): + """ """ + query = super().query(session) + model = self.app.model - printer = profile.get_printer(request.rattail_config) - if not printer: - return {'error': "Couldn't get printer from label profile"} + # always join on Product + return query.join(model.Product) - try: - printer.print_labels([(product, quantity)]) - except Exception, error: - return {'error': str(error)} - return {} + 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.autocomplete', '/products/autocomplete') - config.add_view(ProductsAutocomplete, route_name='products.autocomplete', - renderer='json', permission='products.list') - - config.add_route('products.print_labels', '/products/labels') - config.add_view(print_labels, route_name='products.print_labels', - renderer='json', permission='products.print_labels') - - ProductsView.defaults(config) + defaults(config) diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index e3cbef01..3f47ba3e 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.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,20 +24,29 @@ Progress Views """ -from __future__ import unicode_literals, absolute_import - from tailbone.progress import get_progress_session def progress(request): key = request.matchdict['key'] - session = get_progress_session(request, key, type=request.GET.get('sessiontype')) + session = get_progress_session(request, key, + type=request.GET.get('sessiontype')) + if session.get('complete'): + msg = session.get('success_msg') if msg: request.session.flash(msg) + + bits = session.get('extra_session_bits') + if bits: + for key, value in bits.items(): + request.session[key] = value + elif session.get('error'): - request.session.flash(session.get('error_msg', "An unspecified error occurred."), 'error') + msg = session.get('error_msg', "An unspecified error occurred.") + request.session.flash(msg, 'error') + return session @@ -52,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 new file mode 100644 index 00000000..bcc4cb5d --- /dev/null +++ b/tailbone/views/projects.py @@ -0,0 +1,452 @@ +# -*- 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/>. +# +################################################################################ +""" +Project views +""" + +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 MasterView + + +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(GeneratedProjectView, self).__init__(request) + self.project_handler = self.get_project_handler() + + def get_project_handler(self): + app = self.get_rattail_app() + return app.get_project_handler() + + def create(self): + supported = self.project_handler.get_supported_project_generators() + supported_keys = list(supported) + + 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) + + else: # no project_type + + # 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" + + # 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", + 'project_type': project_type, + 'form': form, + }) + + def generate_project(self, project_type, form): + context = dict(form.validated) + output = self.project_handler.generate_project(project_type, + context=context) + return self.project_handler.zip_output(output) + + def make_project_form(self, project_type): + + # 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): + 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): + defaults(config) diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py index 4f3337d8..e7bebdff 100644 --- a/tailbone/views/purchases/core.py +++ b/tailbone/views/purchases/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,46 +24,12 @@ Views for "true" purchase orders """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model -import formalchemy as fa from webhelpers2.html import HTML, tags -from tailbone import forms from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView - - -class BatchesFieldRenderer(fa.FieldRenderer): - - def render_readonly(self, **kwargs): - batches = self.raw_value - if not batches: - return '' - - enum = self.request.rattail_config.get_enum() - - routes = { - enum.PURCHASE_BATCH_MODE_ORDERING: 'ordering.view', - enum.PURCHASE_BATCH_MODE_RECEIVING: 'receiving.view', - } - - def render(batch): - if batch.executed: - actor = batch.executed_by - pending = '' - else: - actor = batch.created_by - pending = ' (pending)' - display = '{} ({} by {}){}'.format(batch.id_str, - enum.PURCHASE_BATCH_MODE[batch.mode], - actor, pending) - return tags.link_to(display, self.request.route_url(routes[batch.mode], uuid=batch.uuid)) - - items = [HTML.tag('li', c=render(batch)) for batch in batches] - return HTML.tag('ul', c=items) +from tailbone.views import MasterView class PurchaseView(MasterView): @@ -72,23 +38,56 @@ class PurchaseView(MasterView): """ model_class = model.Purchase creatable = False - editable = False has_rows = True model_row_class = model.PurchaseItem row_model_title = 'Purchase Item' + labels = { + 'id': "ID", + } + grid_columns = [ - 'store', + 'id', 'vendor', 'department', 'buyer', 'date_ordered', + 'po_total', 'date_received', + 'invoice_total', 'invoice_number', 'status', ] + form_fields = [ + 'id', + 'store', + 'vendor', + 'department', + 'status', + 'buyer', + 'date_ordered', + 'po_number', + 'po_total', + 'ship_method', + 'notes_to_vendor', + 'invoice_date', + 'invoice_number', + 'invoice_total', + 'date_received', + 'created', + 'created_by', + 'batches', + ] + + row_labels = { + 'vendor_code': "Vendor Item Code", + 'upc': "UPC", + 'po_unit_cost': "PO Unit Cost", + 'po_total': "PO Total", + } + row_grid_columns = [ 'sequence', 'upc', @@ -104,6 +103,27 @@ class PurchaseView(MasterView): 'invoice_total', ] + row_form_fields = [ + 'sequence', + 'vendor_code', + 'upc', + 'product', + 'department', + 'case_quantity', + 'cases_ordered', + 'units_ordered', + 'cases_received', + 'units_received', + 'cases_damaged', + 'units_damaged', + 'cases_expired', + 'units_expired', + 'po_unit_cost', + 'po_total', + 'invoice_unit_cost', + 'invoice_total', + ] + def get_instance_title(self, purchase): if purchase.status >= self.enum.PURCHASE_STATUS_COSTED: if purchase.invoice_date: @@ -119,78 +139,173 @@ 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 unicode(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') - g.filters['date_ordered'].label = "Ordered" + # id + g.set_renderer('id', self.render_id_str) + + # date_ordered g.filters['date_ordered'].default_active = True g.filters['date_ordered'].default_verb = 'equal' - - g.default_sortkey = 'date_ordered' - g.default_sortdir = 'desc' - - g.set_enum('status', self.enum.PURCHASE_STATUS) - g.set_label('date_ordered', "Ordered") + g.set_sort_defaults('date_ordered', 'desc') + + # date_received + g.filters['date_received'].default_active = True + g.filters['date_received'].default_verb = 'equal' g.set_label('date_received', "Received") + + # status + g.set_enum('status', self.enum.PURCHASE_STATUS) + g.filters['status'].default_active = True + g.filters['status'].verbs = ['equal', 'not_equal', 'is_any'] + g.filters['status'].default_verb = 'is_any' + + g.set_type('po_total', 'currency') + g.set_type('invoice_total', 'currency') g.set_label('invoice_number', "Invoice No.") - def _preconfigure_fieldset(self, fs): - fs.store.set(renderer=forms.renderers.StoreFieldRenderer) - fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer) - fs.department.set(renderer=forms.renderers.DepartmentFieldRenderer) - fs.status.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.PURCHASE_STATUS), - readonly=True) - fs.po_number.set(label="PO Number") - fs.po_total.set(label="PO Total", renderer=forms.renderers.CurrencyFieldRenderer) - fs.invoice_total.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.batches.set(renderer=BatchesFieldRenderer) + g.set_link('id') + g.set_link('vendor') + g.set_link('date_ordered') + g.set_link('po_total') + g.set_link('date_received') + g.set_link('invoice_total') + + def configure_form(self, 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) + + # batches + f.set_renderer('batches', self.render_batches) + f.set_readonly('batches') + + # created + f.set_readonly('created') + f.set_readonly('created_by') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.store, - fs.vendor, - fs.department, - fs.status, - fs.buyer, - fs.date_ordered, - fs.date_received, - fs.po_number, - fs.po_total, - fs.invoice_date, - fs.invoice_number, - fs.invoice_total, - fs.created, - fs.created_by, - fs.batches, - ]) if self.viewing: - purchase = fs.model + purchase = f.model_instance if purchase.status == self.enum.PURCHASE_STATUS_ORDERED: - del fs.date_received - del fs.invoice_number - del fs.invoice_total + f.remove('date_received', + 'invoice_number', + 'invoice_total') + + def render_store(self, purchase, field): + store = purchase.store + if not store: + return "" + text = "({}) {}".format(store.id, store.name) + url = self.request.route_url('stores.view', uuid=store.uuid) + return tags.link_to(text, url) + + def render_department(self, purchase, field): + department = purchase.department + if not department: + return "" + if department.number: + text = "({}) {}".format(department.number, department.name) + else: + text = department.name + url = self.request.route_url('departments.view', uuid=department.uuid) + return tags.link_to(text, url) + + def render_batches(self, purchase, field): + batches = purchase.batches + if not batches: + return "" + + routes = { + self.enum.PURCHASE_BATCH_MODE_ORDERING: 'ordering.view', + self.enum.PURCHASE_BATCH_MODE_RECEIVING: 'receiving.view', + } + + def render(batch): + if batch.executed: + actor = batch.executed_by + pending = '' + else: + actor = batch.created_by + pending = ' (pending)' + text = '{} ({} by {}){}'.format(batch.id_str, + self.enum.PURCHASE_BATCH_MODE[batch.mode], + actor, pending) + url = self.request.route_url(routes[batch.mode], uuid=batch.uuid) + return tags.link_to(text, url) + + items = [HTML.tag('li', c=[render(batch)]) for batch in batches] + return HTML.tag('ul', c=items) def delete_instance(self, purchase): """ @@ -210,9 +325,9 @@ 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.default_sortkey = 'sequence' + g.set_sort_defaults('sequence') g.set_type('cases_ordered', 'quantity') g.set_type('units_ordered', 'quantity') @@ -233,45 +348,41 @@ 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 _preconfigure_row_fieldset(self, fs): - fs.vendor_code.set(label="Vendor Item Code") - fs.upc.set(label="UPC") - fs.po_unit_cost.set(label="PO Unit Cost", renderer=forms.renderers.CurrencyFieldRenderer) - fs.po_total.set(label="PO Total", renderer=forms.renderers.CurrencyFieldRenderer) - fs.invoice_unit_cost.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.invoice_total.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.append(fa.Field('department', value=lambda i: '{} {}'.format(i.department_number, i.department_name))) + def configure_row_form(self, f): + super().configure_row_form(f) - def configure_row_fieldset(self, fs): + # quantity fields + f.set_type('case_quantity', 'quantity') + f.set_type('cases_ordered', 'quantity') + f.set_type('units_ordered', 'quantity') + f.set_type('cases_received', 'quantity') + f.set_type('units_received', 'quantity') + f.set_type('cases_damaged', 'quantity') + f.set_type('units_damaged', 'quantity') + f.set_type('cases_expired', 'quantity') + f.set_type('units_expired', 'quantity') - fs.configure( - include=[ - fs.sequence, - fs.vendor_code, - fs.upc, - fs.product, - fs.department, - fs.case_quantity, - fs.cases_ordered, - fs.units_ordered, - fs.cases_received, - fs.units_received, - fs.cases_damaged, - fs.units_damaged, - fs.cases_expired, - fs.units_expired, - fs.po_unit_cost, - fs.po_total, - fs.invoice_unit_cost, - fs.invoice_total, - ]) + # currency fields + f.set_type('po_unit_cost', 'currency') + f.set_type('po_total', 'currency') + f.set_type('invoice_unit_cost', 'currency') + f.set_type('invoice_total', 'currency') + + # department + f.set_renderer('department', self.render_row_department) + + # product + f.set_renderer('product', self.render_product) + + def render_row_department(self, row, field): + return "{} {}".format(row.department_number, row.department_name) def receiving_worksheet(self): purchase = self.get_instance() @@ -281,22 +392,31 @@ class PurchaseView(MasterView): @classmethod def defaults(cls, config): + cls._purchase_defaults(config) + cls._defaults(config) + + @classmethod + def _purchase_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() - cls._defaults(config) - # receiving worksheet config.add_tailbone_permission(permission_prefix, '{}.receiving_worksheet'.format(permission_prefix), "Print receiving worksheet for {}".format(model_title)) config.add_route('{}.receiving_worksheet'.format(route_prefix), '{}/{{{}}}/receiving-worksheet'.format(url_prefix, model_key)) config.add_view(cls, attr='receiving_worksheet', route_name='{}.receiving_worksheet'.format(route_prefix), permission='{}.receiving_worksheet'.format(permission_prefix)) - + + +def defaults(config, **kwargs): + base = globals() + + PurchaseView = kwargs.get('PurchaseView', base['PurchaseView']) + PurchaseView.defaults(config) def includeme(config): - PurchaseView.defaults(config) + defaults(config) diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py index 3056dcb6..7da096eb 100644 --- a/tailbone/views/purchases/credits.py +++ b/tailbone/views/purchases/credits.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. # @@ -24,14 +24,12 @@ Views for "true" purchase credits """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model from webhelpers2.html import tags -from tailbone import forms, grids -from tailbone.views import MasterView2 as MasterView +from tailbone import grids +from tailbone.views import MasterView class PurchaseCreditView(MasterView): @@ -45,50 +43,99 @@ class PurchaseCreditView(MasterView): editable = False checkboxes = True + labels = { + 'upc': "UPC", + 'mispick_upc': "Mispick UPC", + } + grid_columns = [ 'vendor', 'invoice_number', 'invoice_date', 'upc', + 'vendor_item_code', 'brand_name', 'description', 'size', 'cases_shorted', 'units_shorted', + 'credit_total', 'credit_type', 'mispick_upc', 'date_received', 'status', ] - def configure_grid(self, g): - super(PurchaseCreditView, self).configure_grid(g) + form_fields = [ + 'store', + 'vendor', + 'invoice_number', + 'invoice_date', + 'date_ordered', + 'date_shipped', + 'date_received', + 'department_number', + 'department_name', + 'vendor_item_code', + 'upc', + 'product', + 'case_quantity', + 'credit_type', + 'cases_shorted', + 'units_shorted', + 'invoice_line_number', + 'invoice_case_cost', + 'invoice_unit_cost', + 'invoice_total', + 'credit_total', + 'mispick_upc', + 'mispick_product', + 'product_discarded', + 'expiration_date', + 'status', + ] + def configure_grid(self, 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.default_sortkey = 'date_received' - g.default_sortdir = 'desc' + g.set_sort_defaults('date_received', 'desc') + g.set_enum('status', self.enum.PURCHASE_CREDIT_STATUS) g.filters['status'].set_value_renderer(grids.filters.EnumValueRenderer(self.enum.PURCHASE_CREDIT_STATUS)) g.filters['status'].default_active = True g.filters['status'].default_verb = 'not_equal' - g.filters['status'].default_value = self.enum.PURCHASE_CREDIT_STATUS_SATISFIED + # TODO: should not have to convert value to string! + g.filters['status'].default_value = str(self.enum.PURCHASE_CREDIT_STATUS_SATISFIED) - g.set_enum('status', self.enum.PURCHASE_CREDIT_STATUS) # g.set_type('upc', 'gpc') g.set_type('cases_shorted', 'quantity') g.set_type('units_shorted', 'quantity') + g.set_type('credit_total', 'currency') g.set_label('invoice_number', "Invoice No.") - g.set_label('upc', "UPC") + g.set_label('vendor_item_code', "Item Code") g.set_label('brand_name', "Brand") g.set_label('cases_shorted', "Cases") g.set_label('units_shorted', "Units") g.set_label('credit_type', "Type") - g.set_label('mispick_upc', "Mispick UPC") g.set_label('date_received', "Date") + g.set_link('upc') + g.set_link('vendor_item_code') + g.set_link('brand_name') + g.set_link('description') + + def configure_form(self, f): + super(PurchaseCreditView, self).configure_form(f) + + # status + f.set_enum('status', self.enum.PURCHASE_CREDIT_STATUS) + def change_status(self): if self.request.method != 'POST': self.request.session.flash("Sorry, you must POST to change credit status", 'error') @@ -105,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_: @@ -126,16 +173,26 @@ 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 def defaults(cls, config): + cls._purchase_credit_defaults(config) + cls._defaults(config) + + @classmethod + def _purchase_credit_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 name + config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) + # change status config.add_tailbone_permission(permission_prefix, '{}.change_status'.format(permission_prefix), "Change status for {}".format(model_title_plural)) @@ -143,8 +200,13 @@ class PurchaseCreditView(MasterView): config.add_view(cls, attr='change_status', route_name='{}.change_status'.format(route_prefix), permission='{}.change_status'.format(permission_prefix)) - cls._defaults(config) + +def defaults(config, **kwargs): + base = globals() + + PurchaseCreditView = kwargs.get('PurchaseCreditView', base['PurchaseCreditView']) + PurchaseCreditView.defaults(config) def includeme(config): - PurchaseCreditView.defaults(config) + defaults(config) diff --git a/tailbone/views/purchasing/__init__.py b/tailbone/views/purchasing/__init__.py index 8f80b456..09d62909 100644 --- a/tailbone/views/purchasing/__init__.py +++ b/tailbone/views/purchasing/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -32,3 +32,4 @@ from .batch import PurchasingBatchView def includeme(config): config.include('tailbone.views.purchasing.ordering') config.include('tailbone.views.purchasing.receiving') + config.include('tailbone.views.purchasing.costing') diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 738e175e..5e00704e 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.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. # @@ -21,31 +21,35 @@ # ################################################################################ """ -Base views for purchasing batches +Base class for purchasing batch views """ -from __future__ import unicode_literals, absolute_import +import warnings -# from sqlalchemy import orm +from rattail.db.model import PurchaseBatch, PurchaseBatchRow -from rattail.db import model, api -# from rattail.gpc import GPC -from rattail.time import localtime - -import formalchemy as fa -from pyramid import httpexceptions +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags, HTML from tailbone import forms -from tailbone.views.batch import BatchMasterView2 as BatchMasterView +from tailbone.views.batch import BatchMasterView class PurchasingBatchView(BatchMasterView): """ - Master view for purchase order batches. + 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 + + labels = { + 'po_total': "PO Total", + } grid_columns = [ 'id', @@ -55,10 +59,51 @@ class PurchasingBatchView(BatchMasterView): 'date_ordered', 'created', 'created_by', + 'rowcount', 'status_code', 'executed', ] + form_fields = [ + 'id', + 'store', + 'buyer', + 'vendor', + 'description', + 'workflow', + 'department', + 'purchase', + 'vendor_email', + 'vendor_fax', + 'vendor_contact', + 'vendor_phone', + 'date_ordered', + 'date_received', + 'po_number', + 'po_total', + 'invoice_date', + 'invoice_number', + 'invoice_total', + 'notes', + 'created', + 'created_by', + 'status_code', + 'complete', + 'executed', + 'executed_by', + ] + + row_labels = { + '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", + } + # row_grid_columns = [ # 'sequence', # 'upc', @@ -76,34 +121,254 @@ class PurchasingBatchView(BatchMasterView): # 'status_code', # ] + row_form_fields = [ + 'upc', + 'item_id', + 'product', + 'brand_name', + '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', + 'invoice_line_number', + 'invoice_unit_cost', + 'invoice_total', + 'invoice_total_calculated', + 'status_code', + 'credits', + ] + @property 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) - if self.request.has_perm('{}.execute'.format(self.get_permission_prefix())): - g.filters['complete'].default_active = True - g.filters['complete'].default_verb = 'is_true' + # TODO: we used to include the 'complete' filter by default, but it + # seems to likely be confusing for newcomers, so it is no longer + # default. not sure if there are any other implications...? + # if self.request.has_perm('{}.execute'.format(self.get_permission_prefix())): + # g.filters['complete'].default_active = True + # g.filters['complete'].default_verb = 'is_true' + + # invoice_total + g.set_type('invoice_total', 'currency') + g.set_label('invoice_total', "Total") + + # invoice_total_calculated + g.set_type('invoice_total_calculated', 'currency') + g.set_label('invoice_total_calculated', "Total") g.set_label('date_ordered', "Ordered") g.set_label('date_received', "Received") @@ -118,31 +383,367 @@ class PurchasingBatchView(BatchMasterView): # form = super(PurchasingBatchView, self).make_form(batch, **kwargs) # return form - def _preconfigure_fieldset(self, fs): - super(PurchasingBatchView, self)._preconfigure_fieldset(fs) - fs.mode.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.PURCHASE_BATCH_MODE)) - fs.store.set(renderer=forms.renderers.StoreFieldRenderer) - fs.purchase.set(renderer=forms.renderers.PurchaseFieldRenderer, options=[]) - fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer, - attrs={'selected': 'vendor_selected', - 'cleared': 'vendor_cleared'}) - fs.department.set(renderer=forms.renderers.DepartmentFieldRenderer, - options=self.get_department_options()) - fs.buyer.set(renderer=forms.renderers.EmployeeFieldRenderer) - fs.po_number.set(label="PO Number") - fs.po_total.set(label="PO Total", readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) - fs.invoice_total.set(readonly=True, renderer=forms.renderers.CurrencyFieldRenderer) + def configure_common_form(self, f): + super().configure_common_form(f) - fs.append(fa.Field('vendor_email', readonly=True, - value=lambda b: b.vendor.email.address if b.vendor.email else None)) - fs.append(fa.Field('vendor_fax', readonly=True, - value=self.get_vendor_fax_number)) - fs.append(fa.Field('vendor_contact', readonly=True, - value=lambda b: b.vendor.contact or None)) - fs.append(fa.Field('vendor_phone', readonly=True, - value=self.get_vendor_phone_number)) + # po_total + if self.creating: + f.remove_fields('po_total', + 'po_total_calculated') + else: + f.set_readonly('po_total') + f.set_type('po_total', 'currency') + f.set_readonly('po_total_calculated') + f.set_type('po_total_calculated', 'currency') + + def configure_form(self, 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 + workflow = self.request.matchdict.get('workflow_key') + vendor_handler = self.app.get_vendor_handler() + + # 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.config.single_store() + if self.creating: + f.replace('store', 'store_uuid') + if single_store: + 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') + else: + f.set_widget('store_uuid', dfwidget.SelectWidget(values=self.get_store_values())) + f.set_label('store_uuid', "Store") + else: + f.set_readonly('store') + f.set_renderer('store', self.render_store) + + # purchase + f.set_renderer('purchase', self.render_purchase) + if self.editing: + f.set_readonly('purchase') + + # vendor + # fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer, + # attrs={'selected': 'vendor_selected', + # 'cleared': 'vendor_cleared'}) + f.set_renderer('vendor', self.render_vendor) + if self.creating: + f.replace('vendor', 'vendor_uuid') + f.set_label('vendor_uuid', "Vendor") + 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') + + # department + f.set_renderer('department', self.render_department) + if self.creating: + if 'department' in f.fields: + f.replace('department', 'department_uuid') + f.set_node('department_uuid', colander.String()) + dept_options = self.get_department_options() + dept_values = [(v, k) for k, v in dept_options] + dept_values.insert(0, ('', "(unspecified)")) + f.set_widget('department_uuid', dfwidget.SelectWidget(values=dept_values)) + f.set_required('department_uuid', False) + f.set_label('department_uuid', "Department") + else: + f.set_readonly('department') + + # buyer + if 'buyer' in f: + f.set_renderer('buyer', self.render_buyer) + if self.creating or self.editing: + f.replace('buyer', 'buyer_uuid') + f.set_node('buyer_uuid', colander.String(), missing=colander.null) + buyer_display = "" + if self.request.method == 'POST': + if self.request.POST.get('buyer_uuid'): + buyer = self.Session.get(model.Employee, self.request.POST['buyer_uuid']) + if buyer: + buyer_display = str(buyer) + elif self.creating: + buyer = self.app.get_employee(self.request.user) + if buyer: + buyer_display = str(buyer) + f.set_default('buyer_uuid', buyer.uuid) + elif self.editing: + 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) + else: + f.set_readonly('invoice_file') + f.set_renderer('invoice_file', self.render_downloadable_file) + + # invoice_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 = self.batch_handler.get_supported_invoice_parsers(**kwargs) + parser_values = [(p.key, p.display) for p in parsers] + if len(parsers) == 1: + f.set_default('invoice_parser_key', parsers[0].key) + + f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) + else: + f.remove_field('invoice_parser_key') + + # date_ordered + f.set_type('date_ordered', 'date_jquery') + if self.creating: + f.set_default('date_ordered', today) + + # date_received + f.set_type('date_received', 'date_jquery') + if self.creating: + f.set_default('date_received', today) + + # invoice_date + f.set_type('invoice_date', 'date_jquery') + + # po_number + f.set_label('po_number', "PO Number") + + # invoice_total + f.set_readonly('invoice_total') + f.set_type('invoice_total', 'currency') + + # invoice_total_calculated + f.set_readonly('invoice_total_calculated') + f.set_type('invoice_total_calculated', 'currency') + + # vendor_email + f.set_readonly('vendor_email') + f.set_renderer('vendor_email', self.render_vendor_email) + + # vendor_fax + f.set_readonly('vendor_fax') + f.set_renderer('vendor_fax', self.render_vendor_fax) + + # vendor_contact + f.set_readonly('vendor_contact') + f.set_renderer('vendor_contact', self.render_vendor_contact) + + # vendor_phone + f.set_readonly('vendor_phone') + f.set_renderer('vendor_phone', self.render_vendor_phone) + + if self.creating: + f.remove_fields('po_total', + 'invoice_total', + 'complete', + 'vendor_email', + 'vendor_fax', + 'vendor_phone', + '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: + return "" + text = "({}) {}".format(store.id, store.name) + url = self.request.route_url('stores.view', uuid=store.uuid) + return tags.link_to(text, url) + + def render_purchase(self, batch, field): + 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 + + # 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: + return batch.vendor.email.address + + def render_vendor_fax(self, batch, field): + return self.get_vendor_fax_number(batch) + + def render_vendor_contact(self, batch, field): + if batch.vendor.contact: + return str(batch.vendor.contact) + + def render_vendor_phone(self, batch, field): + return self.get_vendor_phone_number(batch) + + def render_department(self, batch, field): + department = batch.department + if not department: + return "" + if department.number: + text = "({}) {}".format(department.number, department.name) + else: + text = department.name + url = self.request.route_url('departments.view', uuid=department.uuid) + return tags.link_to(text, url) + + def render_buyer(self, batch, field): + employee = batch.buyer + if not employee: + return "" + 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) + + 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)\ + .order_by(model.Person.display_name) + + def get_buyer_values(self): + buyers = self.get_buyers() + 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] @@ -156,124 +757,46 @@ class PurchasingBatchView(BatchMasterView): if phone.type == 'Fax': return phone.number - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id, - fs.store, - fs.buyer, - fs.vendor, - fs.department, - fs.purchase, - fs.vendor_email, - fs.vendor_fax, - fs.vendor_contact, - fs.vendor_phone, - fs.date_ordered, - fs.date_received, - fs.po_number, - fs.po_total, - fs.invoice_date, - fs.invoice_number, - fs.invoice_total, - fs.notes, - fs.created, - fs.created_by, - fs.status_code, - fs.complete, - fs.executed, - fs.executed_by, - ]) + def get_batch_kwargs(self, batch, **kwargs): + kwargs = super().get_batch_kwargs(batch, **kwargs) + model = self.app.model - if self.creating: - del fs.po_total - del fs.invoice_total - del fs.complete - del fs.vendor_email - del fs.vendor_fax - del fs.vendor_phone - del fs.vendor_contact - - # default store may be configured - store = self.rattail_config.get('rattail', 'store') - if store: - store = api.get_store(self.Session(), store) - if store: - fs.model.store = store - - # default buyer is current user - if self.request.method != 'POST': - buyer = self.request.user.employee - if buyer: - fs.model.buyer = buyer - - # TODO: something tells me this isn't quite safe.. - # all dates have today as default - today = localtime(self.rattail_config).date() - fs.model.date_ordered = today - fs.model.date_received = today - - elif self.editing: - fs.store.set(readonly=True) - fs.vendor.set(readonly=True) - fs.department.set(readonly=True) - fs.purchase.set(readonly=True) - - 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.Session.query(model.Purchase)\ - .filter(model.Purchase.vendor == vendor) - if mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: - purchases = purchases.filter(model.Purchase.status == self.enum.PURCHASE_STATUS_ORDERED)\ - .order_by(model.Purchase.date_ordered, model.Purchase.created) - elif mode == self.enum.PURCHASE_BATCH_MODE_COSTING: - purchases = purchases.filter(model.Purchase.status == self.enum.PURCHASE_STATUS_RECEIVED)\ - .order_by(model.Purchase.date_received, model.Purchase.created) - - 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, purchase.department or purchase.buyer) - - def get_batch_kwargs(self, batch, mobile=False): - kwargs = super(PurchasingBatchView, self).get_batch_kwargs(batch, mobile=mobile) 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: kwargs['store'] = batch.store elif batch.store_uuid: kwargs['store_uuid'] = batch.store_uuid + + if batch.truck_dump_batch: + kwargs['truck_dump_batch'] = batch.truck_dump_batch + elif batch.truck_dump_batch_uuid: + kwargs['truck_dump_batch_uuid'] = batch.truck_dump_batch_uuid + if batch.vendor: kwargs['vendor'] = batch.vendor 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: kwargs['department_uuid'] = batch.department_uuid + if batch.buyer: kwargs['buyer'] = batch.buyer elif batch.buyer_uuid: kwargs['buyer_uuid'] = batch.buyer_uuid + kwargs['po_number'] = batch.po_number kwargs['po_total'] = batch.po_total @@ -290,14 +813,20 @@ class PurchasingBatchView(BatchMasterView): if self.batch_mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING, self.enum.PURCHASE_BATCH_MODE_COSTING): - if batch.purchase_uuid: - purchase = self.Session.query(model.Purchase).get(batch.purchase_uuid) - assert 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 @@ -320,144 +849,235 @@ 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') g.set_type('units_ordered', 'quantity') + g.set_type('cases_shipped', 'quantity') + g.set_type('units_shipped', 'quantity') g.set_type('cases_received', 'quantity') g.set_type('units_received', 'quantity') g.set_type('po_total', 'currency') - g.set_type('invoice_total', 'currency') + g.set_type('po_total_calculated', 'currency') g.set_type('credits', 'boolean') - g.set_label('upc', "UPC") - g.set_label('brand_name', "Brand") + # we only want the grid columns to have abbreviated labels, + # but *not* the filters + # TODO: would be nice to somehow make this simpler + g.set_label('department_name', "Department") + g.filters['department_name'].label = "Department Name" g.set_label('cases_ordered', "Cases Ord.") + g.filters['cases_ordered'].label = "Cases Ordered" g.set_label('units_ordered', "Units Ord.") + g.filters['units_ordered'].label = "Units Ordered" + g.set_label('cases_shipped', "Cases Shp.") + g.filters['cases_shipped'].label = "Cases Shipped" + g.set_label('units_shipped', "Units Shp.") + g.filters['units_shipped'].label = "Units Shipped" g.set_label('cases_received', "Cases Rec.") + g.filters['cases_received'].label = "Cases Received" g.set_label('units_received', "Units Rec.") - g.set_label('po_total', "Total") + g.filters['units_received'].label = "Units Received" + + # catalog_unit_cost + g.set_renderer('catalog_unit_cost', self.render_row_grid_cost) + 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") + g.filters['invoice_unit_cost'].label = "Invoice Unit Cost" + + # invoice_total + g.set_type('invoice_total', 'currency') g.set_label('invoice_total', "Total") + + # invoice_total_calculated + g.set_type('invoice_total_calculated', 'currency') + g.set_label('invoice_total_calculated', "Total") + + 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: + return "" + return "{:0,.3f}".format(cost) + def make_row_grid_tools(self, batch): return self.make_default_row_grid_tools(batch) def row_grid_extra_class(self, row, i): - if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + if row.status_code in (row.STATUS_PRODUCT_NOT_FOUND, + row.STATUS_COST_NOT_FOUND): return 'warning' - if row.status_code in (row.STATUS_INCOMPLETE, row.STATUS_ORDERED_RECEIVED_DIFFER): + if row.status_code in (row.STATUS_INCOMPLETE, + row.STATUS_CASE_QUANTITY_DIFFERS, + row.STATUS_ORDERED_RECEIVED_DIFFER, + row.STATUS_TRUCKDUMP_UNCLAIMED, + row.STATUS_TRUCKDUMP_PARTCLAIMED, + row.STATUS_OUT_OF_STOCK, + row.STATUS_ON_PO_NOT_INVOICE, + row.STATUS_ON_INVOICE_NOT_PO, + row.STATUS_COST_INCREASE, + row.STATUS_DID_NOT_RECEIVE): return 'notice' - def _preconfigure_row_fieldset(self, fs): - super(PurchasingBatchView, self)._preconfigure_row_fieldset(fs) - fs.upc.set(label="UPC") - fs.item_id.set(label="Item ID") - fs.brand_name.set(label="Brand") - fs.case_quantity.set(renderer=forms.renderers.QuantityFieldRenderer, readonly=True) - fs.cases_ordered.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_ordered.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases_received.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_received.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases_damaged.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_damaged.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases_expired.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_expired.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.cases_mispick.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.units_mispick.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.po_line_number.set(label="PO Line Number") - fs.po_unit_cost.set(label="PO Unit Cost", renderer=forms.renderers.CurrencyFieldRenderer) - fs.po_total.set(label="PO Total", renderer=forms.renderers.CurrencyFieldRenderer) - fs.invoice_unit_cost.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.invoice_total.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.credits.set(readonly=True) - # fs.append(fa.Field('item_lookup', label="Item Lookup Code", required=True, - # validate=self.item_lookup)) - -# 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") - - def configure_row_fieldset(self, fs): - try: + def configure_row_form(self, f): + super().configure_row_form(f) + row = f.model_instance + if self.creating: batch = self.get_instance() - except httpexceptions.HTTPNotFound: - batch = self.get_row_instance().batch + else: + batch = self.get_parent(row) - fs.configure( - include=[ - # fs.item_lookup, - fs.upc, - fs.item_id, - fs.product, - fs.brand_name, - fs.description, - fs.size, - fs.case_quantity, - fs.cases_ordered, - fs.units_ordered, - fs.cases_received, - fs.units_received, - fs.cases_damaged, - fs.units_damaged, - fs.cases_expired, - fs.units_expired, - fs.cases_mispick, - fs.units_mispick, - fs.po_line_number, - fs.po_unit_cost, - fs.po_total, - fs.invoice_line_number, - fs.invoice_unit_cost, - fs.invoice_total, - fs.status_code, - fs.credits, - ]) + # readonly fields + 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') + f.set_type('units_shipped', 'quantity') + f.set_type('cases_received', 'quantity') + f.set_type('units_received', 'quantity') + f.set_type('cases_damaged', 'quantity') + f.set_type('units_damaged', 'quantity') + f.set_type('cases_expired', 'quantity') + 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 + # unit cost fields, b/c currency is rounded to 2 places + f.set_type('po_total', 'currency') + f.set_type('po_total_calculated', 'currency') + + # upc + f.set_type('upc', 'gpc') + + # invoice total + f.set_readonly('invoice_total') + f.set_type('invoice_total', 'currency') + f.set_label('invoice_total', "Invoice Total (Orig.)") + + # invoice total_calculated + f.set_readonly('invoice_total_calculated') + f.set_type('invoice_total_calculated', 'currency') + f.set_label('invoice_total_calculated', "Invoice Total (Calc.)") + + # credits + f.set_readonly('credits') + if self.viewing: + f.set_renderer('credits', self.render_row_credits) if self.creating: - del fs.upc - del fs.product - del fs.po_total - del fs.invoice_total + f.remove_fields( + 'upc', + 'product', + 'po_total', + 'invoice_total', + ) if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: - del fs.cases_received - del fs.units_received + f.remove_fields('cases_received', + 'units_received') elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: - del fs.cases_ordered - del fs.units_ordered + f.remove_fields('cases_ordered', + 'units_ordered') elif self.editing: - # del fs.item_lookup - fs.upc.set(readonly=True) - fs.product.set(readonly=True) - del fs.po_total - del fs.invoice_total - del fs.status_code + f.set_readonly('upc') + f.set_readonly('item_id') + f.set_readonly('product') + f.set_renderer('product', self.render_product) + + # TODO: what's up with this again? + # f.remove_fields('po_total', + # 'invoice_total', + # 'status_code') elif self.viewing: - # del fs.item_lookup - if fs.model.product: - del (fs.brand_name, - fs.description, - fs.size) + if row.product: + f.remove_fields('brand_name', + 'description', + 'size') + f.set_renderer('product', self.render_product) else: - del fs.product + f.remove_field('product') + + 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() + factory = self.get_grid_factory() + + 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 @@ -469,34 +1089,78 @@ class PurchasingBatchView(BatchMasterView): # def after_create_row(self, row): # self.handler.refresh_row(row) -# def after_edit_row(self, row): -# batch = row.batch + def save_edit_row_form(self, form): + """ + Supplements or overrides the default logic, as follows: -# # first undo any totals previously in effect for the row -# if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING and row.po_total: -# batch.po_total -= row.po_total -# elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING and row.invoice_total: -# batch.invoice_total -= row.invoice_total + *Ordering Mode* -# self.handler.refresh_row(row) + So far, we only allow updating the ``cases_ordered`` and/or + ``units_ordered`` quantities; therefore the form data should have one + or both of those fields. + + This data is then passed to the + :meth:`~rattail:rattail.batch.purchase.PurchaseBatchHandler.update_row_quantity()` + method of the batch handler. + + Note that the "normal" logic for this method is not invoked at all, for + ordering batches. + + .. note:: + There is some logic in place for receiving mode, which sort of tries + to update the overall invoice total for the batch, since the form + data might cause those to need adjustment. However the logic is + incomplete as of this writing. + + .. todo:: + Need to fully implement ``save_edit_row_form()`` for receiving batch. + """ + row = form.model_instance + batch = row.batch + + if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: + + # figure out which values need updating + form_data = self.form_deserialized + data = {} + for key in ('cases_ordered', 'units_ordered'): + if key in form_data: + # this is really to convert/avoid colander.null, but the + # handler method also assumes that if we pass a value, it + # will not be None + data[key] = form_data[key] or 0 + if data: + + # let handler do the actual updating + self.handler.update_row_quantity(row, **data) + + else: # *not* ordering mode + + if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: + + # TODO: should stop doing it this way! (use the ordering mode way instead) + # first undo any totals previously in effect for the row + if row.invoice_total: + # TODO: pretty sure this should update the `_calculated` value instead? + # TODO: also, should update the value again after the super() call + batch.invoice_total -= row.invoice_total + + # do the "normal" save logic... + row = super().save_edit_row_form(form) + + # TODO: is this needed? + # self.handler.refresh_row(row) + + return row # def redirect_after_create_row(self, row): # self.request.session.flash("Added item: {} {}".format(row.upc.pretty(), row.product)) # return self.redirect(self.request.current_route_url()) -# def delete_row(self): -# """ -# Update the PO total in addition to marking row as removed. -# """ -# row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid']) -# if not row: -# raise httpexceptions.HTTPNotFound() -# if row.po_total: -# row.batch.po_total -= row.po_total -# if row.invoice_total: -# row.batch.invoice_total -= row.invoice_total -# row.removed = True -# return self.redirect(self.get_action_url('view', row.batch)) + # TODO: seems like this should be master behavior, controlled by setting? + def redirect_after_edit_row(self, row, **kwargs): + parent = self.get_parent(row) + return self.redirect(self.get_action_url('view', parent)) # def get_execute_success_url(self, batch, result, **kwargs): # # if batch execution yielded a Purchase, redirect to it @@ -506,22 +1170,28 @@ 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): + 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)) + # 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') - @classmethod - def defaults(cls, config): - cls._purchasing_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) + +class NewProduct(colander.Schema): + + item_id = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py new file mode 100644 index 00000000..ec4e3ee3 --- /dev/null +++ b/tailbone/views/purchasing/costing.py @@ -0,0 +1,371 @@ +# -*- 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 'costing' (purchasing) batches +""" + +import colander +from deform import widget as dfwidget + +from tailbone import forms +from tailbone.views.purchasing import PurchasingBatchView + + +class CostingBatchView(PurchasingBatchView): + """ + Master view for costing batches + """ + route_prefix = 'invoice_costing' + url_prefix = '/invoice-costing' + model_title = "Invoice Costing Batch" + model_title_plural = "Invoice Costing Batches" + index_title = "Invoice Costing" + downloadable = True + bulk_deletable = True + + labels = { + 'invoice_parser_key': "Invoice Parser", + } + + grid_columns = [ + 'id', + 'vendor', + 'description', + 'department', + 'buyer', + 'date_ordered', + 'created', + 'created_by', + 'rowcount', + 'invoice_total', + 'status_code', + 'executed', + ] + + form_fields = [ + 'id', + 'store', + 'buyer', + 'vendor', + 'costing_workflow', + 'invoice_file', + 'invoice_parser_key', + 'department', + 'purchase', + 'vendor_email', + 'vendor_fax', + 'vendor_contact', + 'vendor_phone', + 'date_ordered', + 'date_received', + 'po_number', + 'po_total', + 'invoice_date', + 'invoice_number', + 'invoice_total', + 'invoice_total_calculated', + 'notes', + 'created', + 'created_by', + 'status_code', + 'complete', + 'executed', + 'executed_by', + ] + + row_grid_columns = [ + 'sequence', + 'upc', + # 'item_id', + 'vendor_code', + 'brand_name', + 'description', + 'size', + 'department_name', + 'cases_received', + 'units_received', + 'case_quantity', + 'catalog_unit_cost', + 'invoice_unit_cost', + # 'invoice_total_calculated', + 'invoice_total', + 'status_code', + ] + + row_form_fields = [ + 'sequence', + 'upc', + 'item_id', + 'product', + 'vendor_code', + 'brand_name', + 'description', + 'size', + 'department_name', + 'case_quantity', + 'cases_ordered', + 'units_ordered', + 'cases_shipped', + 'units_shipped', + 'cases_received', + 'units_received', + 'po_line_number', + 'po_unit_cost', + 'po_total', + 'invoice_line_number', + 'invoice_unit_cost', + 'invoice_total', + 'invoice_total_calculated', + 'catalog_unit_cost', + 'status_code', + ] + + @property + def batch_mode(self): + return self.enum.PURCHASE_BATCH_MODE_COSTING + + def create(self, form=None, **kwargs): + """ + Custom view for creating a new costing 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. + + See also + :meth:`tailbone.views.purchasing.receiving:ReceivingBatchView.create()` + which uses similar logic. + """ + route_prefix = self.get_route_prefix() + workflows = self.handler.supported_costing_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'): + + # 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(CostingBatchView, self).create(**kwargs) + + # okay, at this point we need the user to select a vendor and workflow + self.creating = True + 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) + if len(valid_workflows) == 1: + form.set_default('workflow', valid_workflows[0]) + + # configure vendor field + 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] + 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] + 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(): + 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 configure_form(self, f): + super(CostingBatchView, self).configure_form(f) + route_prefix = self.get_route_prefix() + model = self.model + workflow = self.request.matchdict.get('workflow_key') + + if self.creating: + f.set_fields([ + 'vendor_uuid', + 'costing_workflow', + 'invoice_file', + 'invoice_parser_key', + 'purchase', + ]) + f.set_required('invoice_file') + + # 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']) + assert vendor + + f.set_hidden('vendor_uuid') + f.set_default('vendor_uuid', vendor.uuid) + f.set_widget('vendor_uuid', dfwidget.HiddenWidget()) + + f.insert_after('vendor_uuid', 'vendor_name') + f.set_readonly('vendor_name') + f.set_default('vendor_name', vendor.name) + f.set_label('vendor_name', "Vendor") + + # cancel should take us back to choosing a workflow + f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) + + # costing_workflow + if self.creating and workflow: + f.set_readonly('costing_workflow') + f.set_renderer('costing_workflow', self.render_costing_workflow) + else: + f.remove('costing_workflow') + + # batch_type + if self.creating: + f.set_widget('batch_type', dfwidget.HiddenWidget()) + f.set_default('batch_type', workflow) + f.set_hidden('batch_type') + else: + f.remove_field('batch_type') + + # purchase + field = self.batch_handler.get_purchase_order_fieldname() + if (self.creating and workflow == 'invoice_with_po' + 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'] + info = self.handler.costing_workflow_info(key) + 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._batch_defaults(config) + cls._defaults(config) + + @classmethod + def _costing_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # new costing 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)) + + +@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 NewCostingBatch(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) + + +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 32db947a..c7cc7bfc 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.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,36 +24,67 @@ 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 pyramid.response import FileResponse - -from tailbone import forms +from tailbone.db import Session from tailbone.views.purchasing import PurchasingBatchView class OrderingBatchView(PurchasingBatchView): """ - Master view for purchase order batches. + Master view for "ordering" batches. """ route_prefix = 'ordering' url_prefix = '/ordering' model_title = "Ordering Batch" model_title_plural = "Ordering Batches" index_title = "Ordering" - mobile_creatable = True rows_editable = True - mobile_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", + } + + form_fields = [ + 'id', + 'store', + 'vendor', + 'description', + 'workflow', + 'order_file', + 'order_parser_key', + 'buyer', + 'department', + 'params', + 'purchase', + 'vendor_email', + 'vendor_fax', + 'vendor_contact', + 'vendor_phone', + 'date_ordered', + 'po_number', + 'po_total_calculated', + 'notes', + 'created', + 'created_by', + 'status_code', + 'complete', + 'executed', + 'executed_by', + ] + + row_labels = { + 'po_total_calculated': "PO Total", + } row_grid_columns = [ 'sequence', @@ -66,12 +97,29 @@ class OrderingBatchView(PurchasingBatchView): 'units_ordered', # 'cases_received', # 'units_received', - 'po_total', + 'po_total_calculated', # 'invoice_total', # 'credits', 'status_code', ] + row_form_fields = [ + 'item_entry', + 'item_id', + 'upc', + 'product', + 'brand_name', + 'description', + 'size', + 'case_quantity', + 'cases_ordered', + 'units_ordered', + 'po_line_number', + 'po_unit_cost', + 'po_total_calculated', + 'status_code', + ] + order_form_header_columns = [ "UPC", "Brand", @@ -86,9 +134,139 @@ class OrderingBatchView(PurchasingBatchView): def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_ORDERING - def order_form(self): + def configure_form(self, 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().get_batch_kwargs(batch, **kwargs) + kwargs['ship_method'] = batch.ship_method + kwargs['notes_to_vendor'] = batch.notes_to_vendor + return kwargs + + def configure_row_form(self, f): """ - View for editing batch row data as an order form. + Supplements the default logic as follows: + + When editing, only these fields allow changes; all others are made + read-only: + + * ``cases_ordered`` + * ``units_ordered`` + """ + super().configure_row_form(f) + + # when editing, only certain fields should allow changes + if self.editing: + editable_fields = [ + 'cases_ordered', + 'units_ordered', + ] + for field in f.fields: + if field not in editable_fields: + f.set_readonly(field) + + def scanning_entry(self): + """ + AJAX view to handle user entry/fetch input for "scanning" feature. + """ + data = self.request.json_body + app = self.get_rattail_app() + prodder = app.get_products_handler() + + batch = self.get_instance() + entry = data['entry'] + row = self.handler.quick_entry(self.Session(), batch, entry) + + uom = self.enum.UNIT_OF_MEASURE_EACH + if row.product and row.product.weighed: + uom = self.enum.UNIT_OF_MEASURE_POUND + + cases_ordered = None + if row.cases_ordered: + cases_ordered = float(row.cases_ordered) + + units_ordered = None + if row.units_ordered: + units_ordered = float(row.units_ordered) + + po_case_cost = None + if row.po_unit_cost is not None: + po_case_cost = row.po_unit_cost * (row.case_quantity or 1) + + product_url = None + if row.product_uuid: + product_url = self.request.route_url('products.view', uuid=row.product_uuid) + + product_price = None + if row.product and row.product.regular_price: + product_price = row.product.regular_price.price + + product_price_display = None + if product_price is not None: + product_price_display = app.render_currency(product_price) + + return { + 'ok': True, + 'entry': entry, + 'row': { + 'uuid': row.uuid, + 'item_id': row.item_id, + 'upc_display': row.upc.pretty() if row.upc else None, + 'brand_name': row.brand_name, + 'description': row.description, + 'size': row.size, + 'unit_of_measure_display': self.enum.UNIT_OF_MEASURE[uom], + 'case_quantity': float(row.case_quantity) if row.case_quantity is not None else None, + 'cases_ordered': cases_ordered, + 'units_ordered': units_ordered, + 'po_unit_cost': float(row.po_unit_cost) if row.po_unit_cost is not None else None, + 'po_unit_cost_display': app.render_currency(row.po_unit_cost), + 'po_case_cost': float(po_case_cost) if po_case_cost is not None else None, + 'po_case_cost_display': app.render_currency(po_case_cost), + 'image_url': prodder.get_image_url(upc=row.upc), + 'product_url': product_url, + 'product_price_display': product_price_display, + }, + } + + def scanning_update(self): + """ + AJAX view to handle row data updates for "scanning" feature. + """ + data = self.request.json_body + batch = self.get_instance() + assert batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING + assert not (batch.executed or batch.complete) + + uuid = data.get('row_uuid') + 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: + return {'error': "Row is not active for batch"} + + self.handler.update_row_quantity(row, **data) + return {'ok': True} + + def worksheet(self): + """ + View for editing batch row data as an order form worksheet. """ batch = self.get_instance() if batch.executed: @@ -96,14 +274,15 @@ class OrderingBatchView(PurchasingBatchView): # organize existing batch rows by product order_items = {} - for row in batch.data_rows: - if not row.removed: - order_items[row.product_uuid] = row + for row in batch.active_rows(): + order_items[row.product_uuid] = row # organize vendor catalog costs by dept / subdept departments = {} - costs = self.get_order_form_costs(batch.vendor) - costs = self.sort_order_form_costs(costs) + costs = self.handler.get_order_form_costs(self.Session(), batch.vendor) + costs = self.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) for cost in costs: department = cost.product.department @@ -111,7 +290,7 @@ class OrderingBatchView(PurchasingBatchView): departments.setdefault(department.uuid, department) else: if None not in departments: - department = Object(name=None, number=None) + department = Object(name='', number=None) departments[None] = department department = departments[None] @@ -134,18 +313,20 @@ class OrderingBatchView(PurchasingBatchView): subdept_costs.append(cost) cost._batchrow = order_items.get(cost.product_uuid) - # do anything else needed to satisfy template display requirements etc. - self.decorate_order_form_cost(cost) - # fetch recent purchase history, sort/pad for template convenience - history = self.get_order_form_history(batch, costs, 6) + history = self.handler.get_order_form_history(batch, costs, 6) for i in range(6 - len(history)): history.append(None) history = list(reversed(history)) title = self.get_instance_title(batch) - return self.render_to_response('order_form', { + order_date = batch.date_ordered + if not order_date: + order_date = self.app.today() + + return self.render_to_response('worksheet', { 'batch': batch, + 'order_date': order_date, 'instance': batch, 'instance_title': title, 'instance_url': self.get_action_url('view', batch), @@ -154,159 +335,133 @@ class OrderingBatchView(PurchasingBatchView): 'history': history, 'get_upc': lambda p: p.upc.pretty() if p.upc else '', 'header_columns': self.order_form_header_columns, - 'ignore_cases': self.handler.ignore_cases, + 'ignore_cases': not self.handler.allow_cases(), + 'worksheet_data': self.get_worksheet_data(departments), }) - def get_order_form_history(self, batch, costs, count): + def get_worksheet_data(self, departments): + data = {} + 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 + key = 'cost_{}'.format(cost.uuid) + data['{}_cases'.format(key)] = cases + data['{}_units'.format(key)] = units - # fetch last 6 purchases for this vendor, organize line items by product - history = [] - purchases = self.Session.query(model.Purchase)\ - .filter(model.Purchase.vendor == batch.vendor)\ - .filter(model.Purchase.status >= self.enum.PURCHASE_STATUS_ORDERED)\ - .order_by(model.Purchase.date_ordered.desc(), model.Purchase.created.desc())\ - .options(orm.joinedload(model.Purchase.items)) - for purchase in purchases[:count]: - items = {} - for item in purchase.items: - items[item.product_uuid] = item - history.append({'purchase': purchase, 'items': items}) - - return history + total = 0 + row = cost._batchrow + if row: + total = row.po_total_calculated or row.po_total or 0 + if not (total or cases or units): + display = '' + else: + display = '${:0,.2f}'.format(total) + data['{}_total_display'.format(key)] = display - def get_order_form_costs(self, vendor): - return self.Session.query(model.ProductCost)\ - .join(model.Product)\ - .outerjoin(model.Brand)\ - .filter(model.ProductCost.vendor == vendor)\ - .options(orm.joinedload(model.ProductCost.product)\ - .joinedload(model.Product.department))\ - .options(orm.joinedload(model.ProductCost.product)\ - .joinedload(model.Product.subdepartment)) + return data - def sort_order_form_costs(self, costs): - return costs.order_by(model.Brand.name, - model.Product.description, - model.Product.size) - - def decorate_order_form_cost(self, cost): - pass - - def order_form_update(self): + def worksheet_update(self): """ - Handles AJAX requests to update current batch, from Order Form view. + Handles AJAX requests to update the order quantities for some row + within the current batch, from the worksheet view. POST data should + include: + + * ``product_uuid`` + * ``cases_ordered`` + * ``units_ordered`` + + If a row already exists for the given product, it will be updated; + otherwise a new row is created for the product and then that is + updated. The handler's + :meth:`~rattail:rattail.batch.purchase.PurchaseBatchHandler.update_row_quantity()` + method is invoked to update the row. + + However, if both of the quantities given are empty, and a row exists + for the given product, then that row is removed from the batch, instead + of being updated. If a matching row is not found, it will not be + created. """ + model = self.app.model batch = self.get_instance() - cases_ordered = self.request.POST.get('cases_ordered', '0') - if not cases_ordered or not cases_ordered.isdigit(): + try: + data = self.request.json_body + except json.JSONDecodeError: + data = self.request.POST + + cases_ordered = data.get('cases_ordered') + if cases_ordered is None: + cases_ordered = 0 + elif not isinstance(cases_ordered, int): + if cases_ordered == '': + cases_ordered = 0 + else: + 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)} - cases_ordered = int(cases_ordered) - units_ordered = self.request.POST.get('units_ordered', '0') - if not units_ordered or not units_ordered.isdigit(): + units_ordered = data.get('units_ordered') + if units_ordered is None: + units_ordered = 0 + elif not isinstance(units_ordered, int): + if units_ordered == '': + units_ordered = 0 + else: + 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)} - units_ordered = int(units_ordered) - uuid = self.request.POST.get('product_uuid') - product = self.Session.query(model.Product).get(uuid) if uuid else None + uuid = data.get('product_uuid') + product = self.Session.get(model.Product, uuid) if uuid else None if not product: return {'error': "Product not found"} - row = None - rows = [r for r in batch.data_rows if r.product_uuid == uuid] - if rows: - assert len(rows) == 1 - row = rows[0] - if row.po_total and not row.removed: - batch.po_total -= row.po_total - if cases_ordered or units_ordered: - row.cases_ordered = cases_ordered or None - row.units_ordered = units_ordered or None - row.removed = False - self.handler.refresh_row(row) - else: - row.removed = True + # first we find out which existing row(s) match the given product + matches = [row for row in batch.active_rows() + if row.product_uuid == product.uuid] + if matches and len(matches) != 1: + raise RuntimeError("found too many ({}) matches for product {} in batch {}".format( + len(matches), product.uuid, batch.uuid)) - elif cases_ordered or units_ordered: - row = model.PurchaseBatchRow() - row.sequence = max([0] + [r.sequence for r in batch.data_rows]) + 1 - row.product = product - batch.data_rows.append(row) - row.cases_ordered = cases_ordered or None - row.units_ordered = units_ordered or None - self.handler.refresh_row(row) + row = None + if cases_ordered or units_ordered: + + # make a new row if necessary + if matches: + row = matches[0] + else: + row = self.handler.make_row() + row.product = product + self.handler.add_row(batch, row) + + # update row quantities + try: + self.handler.update_row_quantity(row, cases_ordered=cases_ordered, + units_ordered=units_ordered) + except Exception as error: + return {'error': str(error)} + + else: # empty order quantities + + # remove row if present + if matches: + row = matches[0] + self.handler.do_remove_row(row) + row = None return { - 'row_cases_ordered': '' if not row or row.removed else int(row.cases_ordered or 0), - 'row_units_ordered': '' if not row or row.removed else int(row.units_ordered or 0), - 'row_po_total': '' if not row or row.removed else '${:0,.2f}'.format(row.po_total or 0), + 'row_cases_ordered': int(row.cases_ordered or 0) if row else None, + 'row_units_ordered': int(row.units_ordered or 0) if row else None, + 'row_po_total': '${:0,.2f}'.format(row.po_total or 0) if row else None, + 'row_po_total_calculated': '${:0,.2f}'.format(row.po_total_calculated or 0) if row else None, + 'row_po_total_display': '${:0,.2f}'.format(row.po_total_calculated or row.po_total or 0) if row else None, 'batch_po_total': '${:0,.2f}'.format(batch.po_total or 0), + 'batch_po_total_calculated': '${:0,.2f}'.format(batch.po_total_calculated or 0), + 'batch_po_total_display': '${:0,.2f}'.format(batch.po_total_calculated or batch.po_total or 0), } - def render_mobile_listitem(self, batch, i): - return "({}) {} on {} for ${:0,.2f}".format(batch.id_str, batch.vendor, - batch.date_ordered, batch.po_total or 0) - - def mobile_create(self): - """ - Mobile view for creating a new ordering batch - """ - mode = self.batch_mode - data = {'mode': mode} - - vendor = None - if self.request.method == 'POST' and self.request.POST.get('vendor'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) - if vendor: - - # fetch first to avoid flush below - store = self.rattail_config.get_store(self.Session()) - - batch = self.model_class() - batch.mode = mode - batch.vendor = vendor - batch.store = store - batch.buyer = self.request.user.employee - batch.date_ordered = localtime(self.rattail_config).date() - batch.created_by = self.request.user - batch.po_total = 0 - kwargs = self.get_batch_kwargs(batch, mobile=True) - batch = self.handler.make_batch(self.Session(), **kwargs) - if self.handler.should_populate(batch): - self.handler.populate(batch) - return self.redirect(self.request.route_url('mobile.ordering.view', uuid=batch.uuid)) - - data['index_title'] = self.get_index_title() - data['index_url'] = self.get_index_url(mobile=True) - data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize() - return self.render_to_response('create', data, mobile=True) - - def preconfigure_mobile_fieldset(self, fs): - super(OrderingBatchView, self).preconfigure_mobile_fieldset(fs) - fs.vendor.set(attrs={'hyperlink': False}) - - def configure_mobile_fieldset(self, fs): - fields = [ - fs.vendor, - fs.department, - fs.date_ordered, - fs.po_number, - fs.po_total, - fs.created, - fs.created_by, - fs.notes, - fs.status_code, - fs.complete, - ] - batch = fs.model - if (self.viewing or self.deleting) and batch.executed: - fields.extend([ - fs.executed, - fs.executed_by, - ]) - fs.configure(include=fields) - def download_excel(self): """ Download ordering batch as Excel spreadsheet. @@ -318,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]) @@ -334,41 +490,119 @@ class OrderingBatchView(PurchasingBatchView): path = batch.filepath(self.rattail_config, filename) workbook.save(path) - response = FileResponse(path, request=self.request) - response.content_length = os.path.getsize(path) - response.content_disposition = b'attachment; filename="{}"'.format(filename) - return response + 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().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): - 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() - - # defaults - cls._purchasing_defaults(config) + cls._ordering_defaults(config) + cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) - # ordering form - config.add_tailbone_permission(permission_prefix, '{}.order_form'.format(permission_prefix), - "Edit {} data as worksheet".format(model_title)) - config.add_route('{}.order_form'.format(route_prefix), '{}/{{{}}}/order-form'.format(url_prefix, model_key)) - config.add_view(cls, attr='order_form', route_name='{}.order_form'.format(route_prefix), - permission='{}.order_form'.format(permission_prefix)) - config.add_route('{}.order_form_update'.format(route_prefix), '{}/{{{}}}/order-form/update'.format(url_prefix, model_key)) - config.add_view(cls, attr='order_form_update', route_name='{}.order_form_update'.format(route_prefix), - renderer='json', permission='{}.order_form'.format(permission_prefix)) + @classmethod + def _ordering_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() + model_title_plural = cls.get_model_title_plural() + + # fix permission group label + config.add_tailbone_permission_group(permission_prefix, model_title_plural, + overwrite=False) + + # scanning entry + config.add_route('{}.scanning_entry'.format(route_prefix), '{}/scanning-entry'.format(instance_url_prefix)) + config.add_view(cls, attr='scanning_entry', route_name='{}.scanning_entry'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix), + renderer='json') + + # scanning update + config.add_route('{}.scanning_update'.format(route_prefix), '{}/scanning-update'.format(instance_url_prefix)) + config.add_view(cls, attr='scanning_update', route_name='{}.scanning_update'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix), + renderer='json') # download as Excel - config.add_route('{}.download_excel'.format(route_prefix), '{}/{{uuid}}/excel'.format(url_prefix)) + config.add_route('{}.download_excel'.format(route_prefix), '{}/excel'.format(instance_url_prefix)) config.add_view(cls, attr='download_excel', route_name='{}.download_excel'.format(route_prefix), permission='{}.download_excel'.format(permission_prefix)) config.add_tailbone_permission(permission_prefix, '{}.download_excel'.format(permission_prefix), "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 38fa60ba..01858c98 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.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,60 +24,42 @@ Views for 'receiving' (purchasing) batches """ -from __future__ import unicode_literals, absolute_import +import os +import decimal +import logging +from collections import OrderedDict -import re - -import sqlalchemy as sa +# import humanize from rattail import pod -from rattail.db import model, api -from rattail.gpc import GPC -from rattail.util import pretty_quantity, prettify +from rattail.util import simple_error -import formalchemy as fa -import formencode as fe -from webhelpers2.html import tags +import colander +from deform import widget as dfwidget +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 -class MobileItemStatusFilter(grids.filters.MobileFilter): +log = logging.getLogger(__name__) - value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all'] +POSSIBLE_RECEIVING_MODES = [ + 'received', + 'damaged', + 'expired', + # 'mispick', + 'missing', +] - def filter_equal(self, query, value): - - # TODO: is this accurate (enough) ? - if value == 'incomplete': - return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0, model.PurchaseBatchRow.units_ordered != 0))\ - .filter(model.PurchaseBatchRow.status_code != model.PurchaseBatchRow.STATUS_OK) - - if value == 'unexpected': - return query.filter(sa.and_( - sa.or_( - model.PurchaseBatchRow.cases_ordered == None, - model.PurchaseBatchRow.cases_ordered == 0), - sa.or_( - model.PurchaseBatchRow.units_ordered == None, - model.PurchaseBatchRow.units_ordered == 0))) - - if value == 'damaged': - return query.filter(sa.or_( - model.PurchaseBatchRow.cases_damaged != 0, - model.PurchaseBatchRow.units_damaged != 0)) - - if value == 'expired': - return query.filter(sa.or_( - model.PurchaseBatchRow.cases_expired != 0, - model.PurchaseBatchRow.units_expired != 0)) - - return query - - def iter_choices(self): - for value in self.value_choices: - yield value, prettify(value) +POSSIBLE_CREDIT_TYPES = [ + 'damaged', + 'expired', + # 'mispick', + 'missing', +] class ReceivingBatchView(PurchasingBatchView): @@ -89,311 +71,1954 @@ class ReceivingBatchView(PurchasingBatchView): model_title = "Receiving Batch" model_title_plural = "Receiving Batches" index_title = "Receiving" - creatable = False - rows_deletable = False - mobile_creatable = True - mobile_rows_filterable = True - mobile_rows_creatable = True + downloadable = True + bulk_deletable = 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 + + labels = { + 'truck_dump_batch': "Truck Dump Parent", + 'invoice_parser_key': "Invoice Parser", + } + + grid_columns = [ + 'id', + 'vendor', + 'truck_dump', + 'description', + 'department', + 'date_ordered', + 'created', + 'created_by', + 'rowcount', + 'invoice_total_calculated', + 'status_code', + 'executed', + ] + + form_fields = [ + 'id', + 'batch_type', # TODO: ideally would get rid of this one + 'store', + 'vendor', + 'description', + 'workflow', + 'truck_dump', + 'truck_dump_children_first', + 'truck_dump_children', + 'truck_dump_ready', + 'truck_dump_batch', + 'invoice_file', + 'invoice_parser_key', + 'department', + 'purchase', + 'params', + 'vendor_email', + 'vendor_fax', + 'vendor_contact', + 'vendor_phone', + 'date_ordered', + 'po_number', + 'po_total', + 'date_received', + 'invoice_date', + 'invoice_number', + 'invoice_total', + 'invoice_total_calculated', + 'notes', + 'created', + 'created_by', + 'status_code', + 'truck_dump_status', + 'rowcount', + 'order_quantities_known', + 'receiving_complete', + 'complete', + 'executed', + 'executed_by', + ] 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', 'units_received', - # 'po_total', - 'invoice_total', + 'catalog_unit_cost', + 'invoice_unit_cost', + 'invoice_total_calculated', 'credits', 'status_code', + 'truck_dump_status', + ] + + row_form_fields = [ + 'sequence', + 'item_entry', + '_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', + 'truck_dump_status', + 'claims', + 'credits', + ] + + # convenience list of all quantity attributes involved for a truck dump claim + claim_keys = [ + 'cases_received', + 'units_received', + 'cases_damaged', + 'units_damaged', + 'cases_expired', + 'units_expired', ] @property def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING - def render_mobile_listitem(self, batch, i): - title = "({}) {} for ${:0,.2f} - {}, {}".format( - batch.id_str, - batch.vendor, - batch.po_total or 0, - batch.department, - batch.created_by) + def configure_grid(self, g): + super().configure_grid(g) + + if not self.handler.allow_truck_dump_receiving(): + g.remove('truck_dump') + + def get_supported_vendors(self): + """ """ + vendor_handler = self.app.get_vendor_handler() + vendors = {} + for parser in self.batch_handler.get_supported_invoice_parsers(): + if parser.vendor_key: + vendor = vendor_handler.get_vendor(self.Session(), + parser.vendor_key) + if vendor: + vendors[vendor.uuid] = vendor + vendors = sorted(vendors.values(), key=lambda v: v.name) + return vendors + + def row_deletable(self, row): + + # first run it through the normal logic, if that doesn't like + # it then we won't either + if not super().row_deletable(row): + return False + + # otherwise let handler decide + return self.batch_handler.is_row_deletable(row) + + def get_instance_title(self, 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(): + title = "{} (TRUCK DUMP CHILD)".format(title) return title - def make_mobile_row_filters(self): - """ - Returns a set of filters for the mobile row grid. - """ - filters = grids.filters.GridFilterSet() - filters['status'] = MobileItemStatusFilter('status', default_value='incomplete') - return filters + def configure_form(self, f): + super().configure_form(f) + model = self.model + batch = f.model_instance + allow_truck_dump = self.batch_handler.allow_truck_dump_receiving() + workflow = self.request.matchdict.get('workflow_key') + route_prefix = self.get_route_prefix() - def mobile_create(self): - """ - Mobile view for creating a new receiving batch - """ - mode = self.batch_mode - data = {'mode': mode} + # tweak some things if we are in "step 2" of creating new batch + if self.creating and workflow: - vendor = None - if self.request.method == 'POST' and self.request.POST.get('vendor'): - vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) - if vendor: - data['vendor'] = vendor + # display vendor but do not allow changing + vendor = self.Session.get(model.Vendor, + self.request.matchdict['vendor_uuid']) + assert vendor + f.set_readonly('vendor_uuid') + f.set_default('vendor_uuid', str(vendor)) - if self.request.POST.get('purchase'): - purchase = self.get_purchase(self.request.POST['purchase']) - if purchase: + # cancel should take us back to choosing a workflow + f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) - batch = self.model_class() - batch.mode = mode - batch.vendor = vendor - batch.store = self.rattail_config.get_store(self.Session()) - batch.buyer = self.request.user.employee - batch.created_by = self.request.user - kwargs = self.get_batch_kwargs(batch, mobile=True) - batch = self.handler.make_batch(self.Session(), **kwargs) - if self.handler.should_populate(batch): - self.handler.populate(batch) - return self.redirect(self.request.route_url('mobile.receiving.view', uuid=batch.uuid)) + # TODO: remove this + # batch_type + if self.creating: + f.set_widget('batch_type', dfwidget.HiddenWidget()) + f.set_default('batch_type', workflow) + f.set_hidden('batch_type') + else: + f.remove_field('batch_type') - data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize() - if vendor: - purchases = self.eligible_purchases(vendor.uuid, mode=mode) - data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']] - return self.render_to_response('create', data, mobile=True) + # truck_dump* + if allow_truck_dump: - def get_batch_kwargs(self, batch, mobile=False): - kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile) - if mobile: + # truck_dump + if self.creating or not batch.is_truck_dump_parent(): + f.remove_field('truck_dump') + else: + f.set_readonly('truck_dump') - purchase = self.get_purchase(self.request.POST['purchase']) - kwargs['sms_transaction_number'] = purchase.F1032 + # truck_dump_children_first + if self.creating or not batch.is_truck_dump_parent(): + f.remove_field('truck_dump_children_first') - numbers = [d.F03 for d in purchase.details] - if numbers: - number = max(set(numbers), key=numbers.count) - kwargs['department'] = self.Session.query(model.Department)\ - .filter(model.Department.number == number)\ - .one() + # truck_dump_children + if self.viewing and batch.is_truck_dump_parent(): + f.set_renderer('truck_dump_children', self.render_truck_dump_children) + else: + f.remove_field('truck_dump_children') + + # truck_dump_ready + if self.creating or not batch.is_truck_dump_parent(): + f.remove_field('truck_dump_ready') + + # truck_dump_status + if self.creating or not batch.is_truck_dump_parent(): + f.remove_field('truck_dump_status') + else: + f.set_readonly('truck_dump_status') + f.set_enum('truck_dump_status', model.PurchaseBatch.STATUS) + + # truck_dump_batch + if self.creating: + f.replace('truck_dump_batch', 'truck_dump_batch_uuid') + batches = self.Session.query(model.PurchaseBatch)\ + .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\ + .filter(model.PurchaseBatch.truck_dump == True)\ + .filter(model.PurchaseBatch.complete == True)\ + .filter(model.PurchaseBatch.executed == None)\ + .order_by(model.PurchaseBatch.id) + batch_values = [(b.uuid, "({}) {}, {}".format(b.id_str, b.date_received, b.vendor)) + for b in batches] + batch_values.insert(0, ('', "(please choose)")) + f.set_widget('truck_dump_batch_uuid', forms.widgets.JQuerySelectWidget(values=batch_values)) + f.set_label('truck_dump_batch_uuid', "Truck Dump Parent") + elif batch.is_truck_dump_child(): + f.set_readonly('truck_dump_batch') + f.set_renderer('truck_dump_batch', self.render_truck_dump_batch) + else: + f.remove_field('truck_dump_batch') + + # truck_dump_vendor + if self.creating: + f.set_label('truck_dump_vendor', "Vendor") + f.set_readonly('truck_dump_vendor') + f.set_renderer('truck_dump_vendor', self.render_truck_dump_vendor) else: - kwargs['sms_transaction_number'] = batch.sms_transaction_number + f.remove_fields('truck_dump', + 'truck_dump_children_first', + 'truck_dump_children', + 'truck_dump_ready', + 'truck_dump_status', + 'truck_dump_batch') + + # store + if self.creating: + store = self.rattail_config.get_store(self.Session()) + f.set_default('store_uuid', store.uuid) + # TODO: seems like set_hidden() should also set HiddenWidget + f.set_hidden('store_uuid') + f.set_widget('store_uuid', dfwidget.HiddenWidget()) + + # purchase + field = self.batch_handler.get_purchase_order_fieldname() + if field == 'purchase': + field = 'purchase_uuid' + # TODO: workflow "invoice_with_po" is for costing mode, should rename? + if self.creating and workflow in ( + 'from_po', 'from_po_with_invoice', 'invoice_with_po'): + f.replace('purchase', field) + purchases = self.batch_handler.get_eligible_purchases( + vendor, self.batch_mode) + values = [(self.batch_handler.get_eligible_purchase_key(p), + self.batch_handler.render_eligible_purchase(p)) + for p in purchases] + f.set_widget(field, dfwidget.SelectWidget(values=values)) + if field == 'purchase_uuid': + f.set_label(field, "Purchase Order") + f.set_required(field) + elif self.creating: + f.remove_field('purchase') + else: # not creating + if field != 'purchase_uuid': + f.replace('purchase', field) + f.set_renderer(field, self.render_purchase) + + # department + if self.creating: + f.remove_field('department_uuid') + + # order_quantities_known + 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') + + # now that all fields are setup, some final tweaks based on workflow + if self.creating and workflow: + + if workflow == 'from_scratch': + f.remove('truck_dump_batch_uuid', + 'invoice_file', + 'invoice_parser_key') + + elif workflow == 'from_invoice': + 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_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', + 'invoice_file', + 'invoice_parser_key', + 'date_ordered', + 'po_number', + 'invoice_date', + 'invoice_number') + + elif workflow == 'truck_dump_children_last': + f.remove('truck_dump_batch_uuid', + 'invoice_file', + 'invoice_parser_key', + 'date_ordered', + 'po_number', + 'invoice_date', + 'invoice_number') + + 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().template_kwargs_create(**kwargs) + model = self.model + if self.handler.allow_truck_dump_receiving(): + vmap = {} + batches = self.Session.query(model.PurchaseBatch)\ + .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\ + .filter(model.PurchaseBatch.truck_dump == True)\ + .filter(model.PurchaseBatch.complete == True) + for batch in batches: + vmap[batch.uuid] = batch.vendor_uuid + kwargs['batch_vendor_map'] = vmap return kwargs - def configure_mobile_fieldset(self, fs): - fs.configure(include=[ - fs.vendor.with_renderer(fa.TextFieldRenderer), - fs.department.with_renderer(fa.TextFieldRenderer), - fs.complete, - fs.executed, - fs.executed_by, - ]) - batch = fs.model - if not batch.executed: - del [fs.executed, fs.executed_by] - if not batch.complete: - del fs.complete + def get_batch_kwargs(self, batch, **kwargs): + 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'] + + workflow = kwargs['workflow'] + if workflow == 'from_scratch': + kwargs.pop('truck_dump_batch', None) + kwargs.pop('truck_dump_batch_uuid', None) + elif workflow == 'from_invoice': + pass + 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 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 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 workflow.startswith('truck_dump_child'): + truck_dump = self.get_instance() + kwargs['store'] = truck_dump.store + kwargs['vendor'] = truck_dump.vendor + kwargs['truck_dump_batch'] = truck_dump else: - del fs.complete + raise NotImplementedError + return kwargs - def render_mobile_row_listitem(self, row, i): - description = row.product.full_description if row.product else row.description - return "({}) {}".format(row.upc.pretty(), description) - - # TODO: this view can create new rows, with only a GET query. that should - # probably be changed to require POST; for now we just require the "create - # batch row" perm and call it good.. - def mobile_lookup(self): + def make_po_vs_invoice_breakdown(self, batch): """ - Locate and/or create a row within the batch, according to the given - product UPC, then redirect to the row view page. + 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 + + def delete_instance(self, batch): + """ + Delete all data (files etc.) for the batch. + """ + truck_dump = batch.truck_dump_batch + if batch.is_truck_dump_parent(): + for child in batch.truck_dump_children: + self.delete_instance(child) + super().delete_instance(batch) + if truck_dump: + self.handler.refresh(truck_dump) + + def render_truck_dump_batch(self, batch, field): + truck_dump = batch.truck_dump_batch + if not truck_dump: + return "" + text = "({}) {}".format(truck_dump.id_str, truck_dump.description or '') + url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) + return tags.link_to(text, url) + + def render_truck_dump_vendor(self, batch, field): + truck_dump = self.get_instance() + vendor = truck_dump.vendor + text = "({}) {}".format(vendor.id, vendor.name) + url = self.request.route_url('vendors.view', uuid=vendor.uuid) + return tags.link_to(text, url) + + def render_truck_dump_children(self, batch, field): + contents = [] + children = batch.truck_dump_children + if children: + items = [] + for child in children: + text = "({}) {}".format(child.id_str, child.description or '') + url = self.request.route_url('receiving.view', uuid=child.uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + contents.append(HTML.tag('ul', c=items)) + if not batch.executed and (batch.complete or batch.truck_dump_children_first): + buttons = self.make_truck_dump_child_buttons(batch) + if buttons: + buttons = HTML.literal(' ').join(buttons) + contents.append(HTML.tag('div', class_='buttons', c=[buttons])) + if not contents: + return "" + return HTML.tag('div', c=contents) + + def make_truck_dump_child_buttons(self, batch): + return [ + tags.link_to("Add from Invoice File", self.get_action_url('add_child_from_invoice', batch), class_='button autodisable'), + ] + + def add_child_from_invoice(self): + """ + View for adding a child batch to a truck dump, from invoice file. """ batch = self.get_instance() - row = None - upc = self.request.GET.get('upc', '').strip() - upc = re.sub(r'\D', '', upc) - if upc: + if not batch.is_truck_dump_parent(): + self.request.session.flash("Batch is not a truck dump: {}".format(batch)) + return self.redirect(self.get_action_url('view', batch)) + if batch.executed: + self.request.session.flash("Batch has already been executed: {}".format(batch)) + return self.redirect(self.get_action_url('view', batch)) + if not batch.complete and not batch.truck_dump_children_first: + self.request.session.flash("Batch is not marked as complete: {}".format(batch)) + return self.redirect(self.get_action_url('view', batch)) + self.creating = True + form = self.make_child_from_invoice_form(self.get_model_class()) + return self.create(form=form) - # first try to locate existing batch row by UPC match - provided = GPC(upc, calc_check_digit=False) - checked = GPC(upc, calc_check_digit='upc') - rows = self.Session.query(model.PurchaseBatchRow)\ - .filter(model.PurchaseBatchRow.batch == batch)\ - .filter(model.PurchaseBatchRow.upc.in_((provided, checked)))\ - .filter(model.PurchaseBatchRow.removed == False)\ - .all() + def make_child_from_invoice_form(self, instance, **kwargs): + """ + Creates a new form for the given model class/instance + """ + kwargs['configure'] = self.configure_child_from_invoice_form + return self.make_form(instance=instance, **kwargs) - if rows: - if len(rows) > 1: - log.warning("found multiple UPC matches for {} in batch {}: {}".format( - upc, batch.id_str, batch)) - row = rows[0] + def configure_child_from_invoice_form(self, f): + assert self.creating + truck_dump = self.get_instance() - else: + self.configure_form(f) - # try to locate general product by UPC; add to batch if found - product = api.get_product_by_upc(self.Session(), provided) - if not product: - product = api.get_product_by_upc(self.Session(), checked) - if product: - row = model.PurchaseBatchRow() - row.product = product - batch.add_row(row) - self.handler.refresh_row(row) + # cancel should go back to truck dump parent + f.cancel_url = self.get_action_url('view', truck_dump) + f.set_fields([ + 'batch_type', + 'truck_dump_parent', + 'truck_dump_vendor', + 'invoice_file', + 'invoice_parser_key', + 'invoice_number', + 'description', + 'notes', + ]) + + # batch_type + f.set_widget('batch_type', forms.widgets.ReadonlyWidget()) + f.set_default('batch_type', 'truck_dump_child_from_invoice') + f.set_hidden('batch_type', False) + + # truck_dump_batch_uuid + f.set_readonly('truck_dump_parent') + f.set_renderer('truck_dump_parent', self.render_truck_dump_parent) + + # invoice_parser_key + f.set_required('invoice_parser_key') + + def render_truck_dump_parent(self, batch, field): + truck_dump = self.get_instance() + text = str(truck_dump) + url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) + return tags.link_to(text, url) + + # 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[field], + session=self.Session()) + + department = self.department_for_purchase(purchase) + if department: + batch.department_uuid = department.uuid + + def configure_row_grid(self, 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' + + # 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 + if batch.is_truck_dump_parent() and not batch.truck_dump_children_first: + 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 = self.make_action('transform', + icon='shuffle', + label="Transform to Unit", + url=self.transform_unit_url) + 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) - # if product not even in system, add to batch anyway.. - row = model.PurchaseBatchRow() - row.upc = provided # TODO: why not checked? how to know? - row.description = "(unknown product)" - batch.add_row(row) - self.handler.refresh_row(row) - self.handler.refresh_batch_status(batch) + # truck_dump_status + if not batch.is_truck_dump_parent(): + g.remove('truck_dump_status') + else: + g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS) - self.Session.flush() - return self.redirect(self.mobile_row_route_url('view', uuid=row.uuid)) + def render_simple_unit_cost(self, row, field): + value = getattr(row, field) + if value is None: + return - def mobile_view_row(self): + # 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().row_grid_extra_class(row, i) + + if row.catalog_cost_confirmed: + css_class = '{} catalog_cost_confirmed'.format(css_class or '') + + if row.invoice_cost_confirmed: + css_class = '{} invoice_cost_confirmed'.format(css_class or '') + + 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): + if row.batch.is_truck_dump_parent(): + 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): """ - Mobile view for receiving batch row items. Note that this also handles - updating a row. + 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 row = self.get_row_instance() - form = self.make_mobile_row_form(row) + + # don't even bother showing this page if that's all the + # request was about + if self.request.method == 'GET': + return self.redirect(self.get_row_action_url('view', row)) + + # make sure edit is allowed + if not (self.has_perm('edit_row') and self.row_editable(row)): + raise self.forbidden() + + # 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 = [ + 'received', + 'damaged', + 'expired', + ] context = { 'row': row, + 'batch': batch, + 'parent_instance': batch, 'instance': row, 'instance_title': self.get_row_instance_title(row), 'parent_model_title': self.get_model_title(), - 'product_image_url': pod.get_image_url(self.rattail_config, row.upc), - 'form': form, + 'product_image_url': self.get_row_image_url(row), + 'allow_expired': self.handler.allow_expired_credits(), + 'allow_cases': self.handler.allow_cases(), + 'quick_receive': False, + 'quick_receive_all': False, } - if self.request.has_perm('{}.edit'.format(self.get_row_permission_prefix())): - update_form = forms.SimpleForm(self.request, schema=ReceivingForm) - if update_form.validate(): - row = update_form.data['row'] - mode = update_form.data['mode'] - cases = update_form.data['cases'] - units = update_form.data['units'] - if cases: - setattr(row, 'cases_{}'.format(mode), - (getattr(row, 'cases_{}'.format(mode)) or 0) + cases) - if units: - setattr(row, 'units_{}'.format(mode), - (getattr(row, 'units_{}'.format(mode)) or 0) + units) + schema = ReceiveRowForm().bind(session=self.Session()) + form = forms.Form(schema=schema, request=self.request) + form.cancel_url = self.get_row_action_url('view', row) - # if mode in ('damaged', 'expired', 'mispick'): - if mode in ('damaged', 'expired'): - self.attach_credit(row, mode, cases, units, - expiration_date=update_form.data['expiration_date'], - # discarded=update_form.data['trash'], - # mispick_product=shipped_product) - ) + # mode + mode_values = [(mode, mode) for mode in possible_modes] + mode_widget = dfwidget.SelectWidget(values=mode_values) + form.set_widget('mode', mode_widget) - # first undo any totals previously in effect for the row, then refresh - if row.invoice_total: - row.batch.invoice_total -= row.invoice_total - self.handler.refresh_row(row) + # 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) - return self.redirect(self.request.route_url('mobile.{}.view'.format(self.get_route_prefix()), uuid=row.batch_uuid)) + # expiration_date + form.set_type('expiration_date', 'date_jquery') - if not row.cases_ordered and not row.units_ordered: - self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') - return self.render_to_response('view_row', context, mobile=True) + # TODO: what is this one about again? + form.remove_field('quick_receive') + + if form.validate(): + + # handler takes care of the row receiving logic for us + kwargs = dict(form.validated) + kwargs['cases'] = kwargs['quantity']['cases'] + kwargs['units'] = kwargs['quantity']['units'] + del kwargs['quantity'] + self.handler.receive_row(row, **kwargs) + + # keep track of last-used uom, although we just track + # whether or not it was 'CS' since the unit_uom can vary + # TODO: should this be done for desktop too somehow? + sticky_case = None + # if mobile and not form.validated['quick_receive']: + # cases = form.validated['cases'] + # units = form.validated['units'] + # if cases and not units: + # sticky_case = True + # elif units and not cases: + # sticky_case = False + if sticky_case is not None: + self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case + + return self.redirect(self.get_row_action_url('view', row)) + + # unit_uom can vary by product + context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + + if context['quick_receive'] and context['quick_receive_all']: + if context['allow_cases']: + context['quick_receive_uom'] = 'CS' + raise NotImplementedError("TODO: add CS support for quick_receive_all") + else: + context['quick_receive_uom'] = context['unit_uom'] + accounted_for = self.handler.get_units_accounted_for(row) + remainder = self.handler.get_units_ordered(row) - accounted_for + + if accounted_for: + # some product accounted for; button should receive "remainder" only + if remainder: + remainder = app.render_quantity(remainder) + context['quick_receive_quantity'] = remainder + context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom']) + else: + # unless there is no remainder, in which case disable it + context['quick_receive'] = False + + else: # nothing yet accounted for, button should receive "all" + if not remainder: + raise ValueError("why is remainder empty?") + remainder = app.render_quantity(remainder) + context['quick_receive_quantity'] = remainder + context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom']) + + # effective uom can vary in a few ways...the basic default is 'CS' if + # self.default_uom_is_case is true, otherwise whatever unit_uom is. + sticky_case = None + # if mobile: + # # TODO: should do this for desktop also, but rename the session variable + # sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case') + if sticky_case is None: + context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom'] + elif sticky_case: + context['uom'] = 'CS' + else: + context['uom'] = context['unit_uom'] + if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered: + context['uom'] = context['unit_uom'] + + # # TODO: should do this for desktop in addition to mobile? + # if mobile and batch.order_quantities_known and not row.cases_ordered and not row.units_ordered: + # warn = True + # if batch.is_truck_dump_parent() and row.product: + # uuids = [child.uuid for child in batch.truck_dump_children] + # if uuids: + # count = self.Session.query(model.PurchaseBatchRow)\ + # .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\ + # .filter(model.PurchaseBatchRow.product == row.product)\ + # .count() + # if count: + # warn = False + # if warn: + # self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') + + # # TODO: should do this for desktop in addition to mobile? + # if mobile: + # # maybe alert user if they've already received some of this product + # alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received', + # default=False) + # if alert_received: + # if self.handler.get_units_confirmed(row): + # msg = "You have already received some of this product; last update was {}.".format( + # humanize.naturaltime(make_utc() - row.modified)) + # self.request.session.flash(msg, 'receiving-warning') + + context['form'] = form + context['dform'] = form.make_deform_form() + context['parent_url'] = self.get_action_url('view', batch) + context['parent_title'] = self.get_instance_title(batch) + return self.render_to_response('receive_row', context) + + def declare_credit(self): + """ + View for declaring a credit, i.e. converting some "received" or similar + 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)}) - def attach_credit(self, row, credit_type, cases, units, expiration_date=None, discarded=None, mispick_product=None): batch = row.batch - credit = model.PurchaseBatchCredit() - credit.credit_type = credit_type - credit.store = batch.store - credit.vendor = batch.vendor - credit.date_ordered = batch.date_ordered - credit.date_shipped = batch.date_shipped - credit.date_received = batch.date_received - credit.invoice_number = batch.invoice_number - credit.invoice_date = batch.invoice_date - credit.product = row.product - credit.upc = row.upc - credit.brand_name = row.brand_name - credit.description = row.description - credit.size = row.size - credit.department_number = row.department_number - credit.department_name = row.department_name - credit.case_quantity = row.case_quantity - credit.cases_shorted = cases - credit.units_shorted = units - credit.invoice_line_number = row.invoice_line_number - credit.invoice_case_cost = row.invoice_case_cost - credit.invoice_unit_cost = row.invoice_unit_cost - credit.invoice_total = row.invoice_total - credit.product_discarded = discarded - if credit_type == 'expired': - credit.expiration_date = expiration_date - elif credit_type == 'mispick' and mispick_product: - credit.mispick_product = mispick_product - credit.mispick_upc = mispick_product.upc - if mispick_product.brand: - credit.mispick_brand_name = mispick_product.brand.name - credit.mispick_description = mispick_product.description - credit.mispick_size = mispick_product.size - row.credits.append(credit) - return credit + context = { + 'row': row, + 'batch': batch, + 'parent_instance': batch, + 'instance': row, + 'instance_title': self.get_row_instance_title(row), + 'parent_model_title': self.get_model_title(), + 'product_image_url': self.get_row_image_url(row), + 'allow_expired': self.handler.allow_expired_credits(), + 'allow_cases': self.handler.allow_cases(), + } + + schema = DeclareCreditForm() + form = forms.Form(schema=schema, request=self.request) + form.cancel_url = self.get_row_action_url('view', row) + + # credit_type + values = [(m, m) for m in POSSIBLE_CREDIT_TYPES] + 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(): + + # handler takes care of the row receiving logic for us + kwargs = dict(form.validated) + kwargs['cases'] = kwargs['quantity']['cases'] + kwargs['units'] = kwargs['quantity']['units'] + del kwargs['quantity'] + try: + result = self.handler.can_declare_credit(row, **kwargs) + except Exception as error: + self.request.session.flash("Handler says you can't declare that credit: {}".format(error), 'error') + else: + if result: + self.handler.declare_credit(row, **kwargs) + return self.redirect(self.get_row_action_url('view', row)) + + self.request.session.flash("Handler says you can't declare that credit; not sure why", 'error') + + context['form'] = form + context['dform'] = form.make_deform_form() + context['parent_url'] = self.get_action_url('view', batch) + 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.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: + if self.request.method == 'POST': + raise self.notfound() + return {'error': "Row not found."} + + def normalize(product): + data = { + 'upc': product.upc, + 'item_id': product.item_id, + 'description': product.description, + 'size': product.size, + 'case_quantity': None, + 'cases_received': row.cases_received, + } + cost = product.cost_for_vendor(batch.vendor) + if cost: + data['case_quantity'] = cost.case_size + return data + + if self.request.method == 'POST': + self.handler.transform_pack_to_unit(row) + self.request.session.flash("Transformed pack to unit item for: {}".format(row.product)) + return self.redirect(self.get_action_url('view', batch)) + + pack_data = normalize(row.product) + pack_data['units_received'] = row.units_received + unit_data = normalize(row.product.unit) + unit_data['units_received'] = None + if row.units_received: + unit_data['units_received'] = row.units_received * row.product.pack_size + diff = self.make_diff(pack_data, unit_data, monospace=True) + return self.render_to_response('transform_unit_row', { + 'batch': batch, + 'row': row, + 'diff': diff, + }) + + def configure_row_form(self, 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', + ] + for name in f.fields: + if name not in mutable: + f.set_readonly(name) + + # invoice totals + f.set_label('invoice_total', "Invoice Total (Orig.)") + f.set_label('invoice_total_calculated', "Invoice Total (Calc.)") + + # claims + f.set_readonly('claims') + if batch.is_truck_dump_parent(): + f.set_renderer('claims', self.render_parent_row_claims) + f.set_helptext('claims', "Parent row is claimed by these child rows.") + elif batch.is_truck_dump_child(): + f.set_renderer('claims', self.render_child_row_claims) + f.set_helptext('claims', "Child row makes claims against these parent rows.") + else: + f.remove_field('claims') + + # truck_dump_status + if self.creating or not batch.is_truck_dump_parent(): + f.remove_field('truck_dump_status') + else: + f.set_readonly('truck_dump_status') + f.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS) + + # misc. labels + f.set_label('vendor_code', "Vendor Item Code") + + def render_parent_row_claims(self, row, field): + items = [] + for claim in row.claims: + child_row = claim.claiming_row + child_batch = child_row.batch + text = child_batch.id_str + if child_batch.description: + text = "{} ({})".format(text, child_batch.description) + text = "{}, row {}".format(text, child_row.sequence) + url = self.get_row_action_url('view', child_row) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) + + def render_child_row_claims(self, row, field): + items = [] + for claim in row.truck_dump_claims: + parent_row = claim.claimed_row + parent_batch = parent_row.batch + text = parent_batch.id_str + if parent_batch.description: + text = "{} ({})".format(text, parent_batch.description) + text = "{}, row {}".format(text, parent_row.sequence) + url = self.get_row_action_url('view', parent_row) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + return HTML.tag('ul', c=items) + + def validate_row_form(self, form): + + # if normal validation fails, stop there + if not super().validate_row_form(form): + return False + + # if user is editing row from truck dump child, then we must further + # validate the form to ensure whatever new amounts they've requested + # would in fact fall within the bounds of what is available from the + # truck dump parent batch... + if self.editing: + batch = self.get_instance() + if batch.is_truck_dump_child(): + old_row = self.get_row_instance() + case_quantity = old_row.case_quantity + + # get all "existing" (old) claim amounts + old_claims = {} + for claim in old_row.truck_dump_claims: + for key in self.claim_keys: + amount = getattr(claim, key) + if amount is not None: + old_claims[key] = old_claims.get(key, 0) + amount + + # get all "proposed" (new) claim amounts + new_claims = {} + for key in self.claim_keys: + amount = form.validated[key] + if amount is not colander.null and amount is not None: + # do not allow user to request a negative claim amount + if amount < 0: + self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error') + return False + new_claims[key] = amount + + # figure out what changes are actually being requested + claim_diff = {} + for key in new_claims: + if key not in old_claims: + claim_diff[key] = new_claims[key] + elif new_claims[key] != old_claims[key]: + claim_diff[key] = new_claims[key] - old_claims[key] + # do not allow user to request a negative claim amount + if claim_diff[key] < (0 - old_claims[key]): + self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error') + return False + for key in old_claims: + if key not in new_claims: + claim_diff[key] = 0 - old_claims[key] + + # find all rows from truck dump parent which "may" pertain to child row + # TODO: perhaps would need to do a more "loose" match on UPC also? + if not old_row.product_uuid: + raise NotImplementedError("Don't (yet) know how to handle edit for row with no product") + parent_rows = [row for row in batch.truck_dump_batch.active_rows() + if row.product_uuid == old_row.product_uuid] + + # NOTE: "confirmed" are the proper amounts which exist in the + # parent batch. "claimed" are the amounts claimed by this row. + + # get existing "confirmed" and "claimed" amounts for all + # (possibly related) truck dump parent rows + confirmed = {} + claimed = {} + for parent_row in parent_rows: + for key in self.claim_keys: + amount = getattr(parent_row, key) + if amount is not None: + confirmed[key] = confirmed.get(key, 0) + amount + for claim in parent_row.claims: + for key in self.claim_keys: + amount = getattr(claim, key) + if amount is not None: + claimed[key] = claimed.get(key, 0) + amount + + # now to see if user's request is possible, given what is + # available... + + # first we must (pretend to) "relinquish" any claims which are + # to be reduced or eliminated, according to our diff + for key, amount in claim_diff.items(): + if amount < 0: + amount = abs(amount) # make positive, for more readable math + if key not in claimed or claimed[key] < amount: + self.request.session.flash("Cannot relinquish more claims than the " + "parent batch has to offer.", 'error') + return False + claimed[key] -= amount + + # next we must determine if any "new" requests would increase + # the claim(s) beyond what is available + for key, amount in claim_diff.items(): + if amount > 0: + claimed[key] = claimed.get(key, 0) + amount + if key not in confirmed or confirmed[key] < claimed[key]: + self.request.session.flash("Cannot request to claim more product than " + "is available in Truck Dump Parent batch", 'error') + return False + + # looks like the claim diff is all good, so let's attach that + # to the form now and then pick this up again in save() + form._claim_diff = claim_diff + + # all validation went ok + return True + + def save_edit_row_form(self, form): + model = self.model + batch = self.get_instance() + row = self.objectify(form) + + # editing a row for truck dump child batch can be complicated... + if batch.is_truck_dump_child(): + + # grab the claim diff which we attached to the form during validation + claim_diff = form._claim_diff + + # first we must "relinquish" any claims which are to be reduced or + # eliminated, according to our diff + for key, amount in claim_diff.items(): + if amount < 0: + amount = abs(amount) # make positive, for more readable math + + # we'd prefer to find an exact match, i.e. there was a 1CS + # claim and our diff said to reduce by 1CS + matches = [claim for claim in row.truck_dump_claims + if getattr(claim, key) == amount] + if matches: + claim = matches[0] + setattr(claim, key, None) + + else: + # but if no exact match(es) then we'll just whittle + # away at whatever (smallest) claims we do find + possible = [claim for claim in row.truck_dump_claims + if getattr(claim, key) is not None] + for claim in sorted(possible, key=lambda claim: getattr(claim, key)): + previous = getattr(claim, key) + if previous: + if previous >= amount: + if (previous - amount): + setattr(claim, key, previous - amount) + else: + setattr(claim, key, None) + amount = 0 + break + else: + setattr(claim, key, None) + amount -= previous + + if amount: + raise NotImplementedError("Had leftover amount when \"relinquishing\" claim(s)") + + # next we must stake all new claim(s) as requested, per our diff + for key, amount in claim_diff.items(): + if amount > 0: + + # if possible, we'd prefer to add to an existing claim + # which already has an amount for this key + existing = [claim for claim in row.truck_dump_claims + if getattr(claim, key) is not None] + if existing: + claim = existing[0] + setattr(claim, key, getattr(claim, key) + amount) + + # next we'd prefer to add to an existing claim, of any kind + elif row.truck_dump_claims: + claim = row.truck_dump_claims[0] + setattr(claim, key, (getattr(claim, key) or 0) + amount) + + else: + # otherwise we must create a new claim... + + # find all rows from truck dump parent which "may" pertain to child row + # TODO: perhaps would need to do a more "loose" match on UPC also? + if not row.product_uuid: + raise NotImplementedError("Don't (yet) know how to handle edit for row with no product") + parent_rows = [parent_row for parent_row in batch.truck_dump_batch.active_rows() + if parent_row.product_uuid == row.product_uuid] + + # remove any parent rows which are fully claimed + # TODO: should perhaps leverage actual amounts for this, instead + parent_rows = [parent_row for parent_row in parent_rows + if parent_row.status_code != parent_row.STATUS_TRUCKDUMP_CLAIMED] + + # try to find a parent row which is exact match on claim amount + matches = [parent_row for parent_row in parent_rows + if getattr(parent_row, key) == amount] + if matches: + + # make the claim against first matching parent row + claim = model.PurchaseBatchRowClaim() + claim.claimed_row = parent_rows[0] + setattr(claim, key, amount) + row.truck_dump_claims.append(claim) + + else: + # but if no exact match(es) then we'll just whittle + # away at whatever (smallest) parent rows we do find + for parent_row in sorted(parent_rows, lambda prow: getattr(prow, key)): + + available = getattr(parent_row, key) - sum([getattr(claim, key) for claim in parent_row.claims]) + if available: + if available >= amount: + # make claim against this parent row, making it fully claimed + claim = model.PurchaseBatchRowClaim() + claim.claimed_row = parent_row + setattr(claim, key, amount) + row.truck_dump_claims.append(claim) + amount = 0 + break + else: + # make partial claim against this parent row + claim = model.PurchaseBatchRowClaim() + claim.claimed_row = parent_row + setattr(claim, key, available) + row.truck_dump_claims.append(claim) + amount -= available + + if amount: + raise NotImplementedError("Had leftover amount when \"staking\" claim(s)") + + # now we must be sure to refresh all truck dump parent batch rows + # which were affected. but along with that we also should purge + # any empty claims, i.e. those which were fully relinquished + pending_refresh = set() + for claim in list(row.truck_dump_claims): + parent_row = claim.claimed_row + if claim.is_empty(): + row.truck_dump_claims.remove(claim) + self.Session.flush() + pending_refresh.add(parent_row) + for parent_row in pending_refresh: + self.handler.refresh_row(parent_row) + self.handler.refresh_batch_status(batch.truck_dump_batch) + + self.after_edit_row(row) + self.Session.flush() + return row + + def redirect_after_edit_row(self, row, **kwargs): + return self.redirect(self.get_row_action_url('view', row)) + + def update_row_cost(self): + """ + AJAX view for updating various cost fields in a data row. + """ + app = self.get_rattail_app() + model = self.model + batch = self.get_instance() + data = dict(get_form_data(self.request)) + + # validate row + uuid = data.get('row_uuid') + row = self.Session.get(model.PurchaseBatchRow, uuid) if uuid else None + if not row or row.batch is not batch: + return {'error': "Row not found"} + + # validate/normalize cost value(s) + for field in ('catalog_unit_cost', 'invoice_unit_cost'): + if field in data: + cost = data[field] + if cost == '': + return {'error': "You must specify a cost"} + try: + cost = decimal.Decimal(str(cost)) + except decimal.InvalidOperation: + return {'error': "Cost is not valid!"} + else: + data[field] = cost + + # okay, update our row + self.handler.update_row_cost(row, **data) + + self.Session.flush() + self.Session.refresh(row) + return { + 'row': { + 'catalog_unit_cost': self.render_simple_unit_cost(row, 'catalog_unit_cost'), + 'catalog_cost_confirmed': row.catalog_cost_confirmed, + 'invoice_unit_cost': self.render_simple_unit_cost(row, 'invoice_unit_cost'), + 'invoice_cost_confirmed': row.invoice_cost_confirmed, + 'invoice_total_calculated': app.render_currency(row.invoice_total_calculated), + }, + 'batch': { + 'invoice_total_calculated': app.render_currency(batch.invoice_total_calculated), + }, + } + + def save_quick_row_form(self, form): + batch = self.get_instance() + entry = form.validated['quick_entry'] + row = self.handler.quick_entry(self.Session(), batch, entry) + return row + + def get_row_image_url(self, row): + 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. + """ + batch = self.get_instance() + return self.handler_action(batch, 'auto_receive') + + 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 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.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("failed to confirm all costs for batch: %s", batch) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = f"Failed to confirm costs: {simple_error(error)}" + progress.session.save() + + else: + session.commit() + session.refresh(batch) + success_url = self.get_action_url('view', batch) + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + 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): - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() - model_key = cls.get_model_key() - permission_prefix = cls.get_permission_prefix() - - # mobile lookup (note perm; this view can create new rows) - config.add_route('mobile.{}.lookup'.format(route_prefix), '/mobile{}/{{{}}}/lookup'.format(url_prefix, model_key)) - config.add_view(cls, attr='mobile_lookup', route_name='mobile.{}.lookup'.format(route_prefix), - renderer='json', permission='{}.create_row'.format(permission_prefix)) - - cls._purchasing_defaults(config) + cls._receiving_defaults(config) + cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) + @classmethod + def _receiving_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + route_prefix = cls.get_route_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() -class ValidBatchRow(forms.validators.ModelValidator): - model_class = model.PurchaseBatchRow + # 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), + permission='{}.edit_row'.format(permission_prefix)) - def _to_python(self, value, state): - row = super(ValidBatchRow, self)._to_python(value, state) - if row.batch.executed: - raise fe.Invalid("Batch has already been executed", value, state) - return row + # declare credit for row + config.add_route('{}.declare_credit'.format(route_prefix), '{}/rows/{{row_uuid}}/declare-credit'.format(instance_url_prefix)) + 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), + permission='{}.edit_row'.format(permission_prefix), + renderer='json') + + # add TD child batch, from invoice file + config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/add-child-from-invoice'.format(instance_url_prefix)) + config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) + + # transform TD parent row from "pack" to "unit" item + config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/transform-unit'.format(instance_url_prefix)) + config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix), renderer='json') + + # confirm all costs + config.add_route(f'{route_prefix}.confirm_all_costs', + f'{instance_url_prefix}/confirm-all-costs', + request_method='POST') + config.add_view(cls, attr='confirm_all_costs', + route_name=f'{route_prefix}.confirm_all_costs', + permission=f'{permission_prefix}.edit_row') + + # auto-receive all items + config.add_tailbone_permission(permission_prefix, + '{}.auto_receive'.format(permission_prefix), + "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 ReceivingForm(forms.Schema): - allow_extra_fields = True - filter_extra_fields = True - row = ValidBatchRow() - mode = fe.validators.OneOf(['received', 'damaged', 'expired', - # 'mispick', - ]) - # product = forms.validators.ValidProduct() - # upc = forms.validators.ValidGPC() - # brand_name = fe.validators.String() - # description = fe.validators.String() - # size = fe.validators.String() - # case_quantity = fe.validators.Number() - cases = fe.validators.Number() - units = fe.validators.Number() - expiration_date = fe.validators.DateValidator() - # trash = fe.validators.Bool() - # ordered_product = forms.validators.ValidProduct() +class ReceiveRowForm(colander.MappingSchema): + + mode = colander.SchemaNode(colander.String(), + validator=colander.OneOf( + POSSIBLE_RECEIVING_MODES)) + + quantity = forms.types.ProductQuantity() + + expiration_date = colander.SchemaNode(colander.Date(), + widget=dfwidget.TextInputWidget(), + missing=colander.null) + + 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( + POSSIBLE_CREDIT_TYPES)) + + quantity = forms.types.ProductQuantity() + + expiration_date = colander.SchemaNode(colander.Date(), + widget=dfwidget.TextInputWidget(), + missing=colander.null) + + +def defaults(config, **kwargs): + base = globals() + + ReceivingBatchView = kwargs.get('ReceivingBatchView', base['ReceivingBatchView']) + ReceivingBatchView.defaults(config) def includeme(config): - ReceivingBatchView.defaults(config) + defaults(config) diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index 59646cfc..ef090c22 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -28,38 +28,91 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView -class ReportCodesView(MasterView): +class ReportCodeView(MasterView): """ Master view for the ReportCode class. """ model_class = model.ReportCode model_title = "Report Code" has_versions = True + touchable = True + results_downloadable_xlsx = True grid_columns = [ 'code', 'name', ] + form_fields = [ + 'code', + 'name', + ] + + has_rows = True + model_row_class = model.Product + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'upc', + 'brand', + 'description', + 'size', + 'department', + 'vendor', + 'regular_price', + 'current_price', + ] + def configure_grid(self, g): - super(ReportCodesView, self).configure_grid(g) + super(ReportCodeView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'code' + g.set_sort_defaults('code') g.set_link('code') g.set_link('name') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.code, - fs.name, - ]) - return fs + def get_row_data(self, reportcode): + return self.Session.query(model.Product)\ + .filter(model.Product.report_code == reportcode) + + def get_parent(self, product): + return product.report_code + + def configure_row_grid(self, g): + super(ReportCodeView, self).configure_row_grid(g) + + app = self.get_rattail_app() + self.handler = app.get_products_handler() + g.set_renderer('regular_price', self.render_price) + g.set_renderer('current_price', self.render_price) + + g.set_sort_defaults('upc') + + def render_price(self, product, field): + if not product.not_for_sale: + price = product[field] + if price: + return self.handler.render_price(price) + + def row_view_action_url(self, product, i): + return self.request.route_url('products.view', uuid=product.uuid) + +# TODO: deprecate / remove this +ReportCodesView = ReportCodeView + + +def defaults(config, **kwargs): + base = globals() + + ReportCodeView = kwargs.get('ReportCodeView', base['ReportCodeView']) + ReportCodeView.defaults(config) def includeme(config): - ReportCodesView.defaults(config) + defaults(config) diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index ac349138..099224be 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.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,41 +24,49 @@ Reporting views """ -from __future__ import unicode_literals, absolute_import - +import calendar +import json import re +import datetime +import logging +from collections import OrderedDict import rattail -from rattail import enum -from rattail.db import model +from rattail.db.model import ReportOutput from rattail.files import resource_path -from rattail.time import localtime +from rattail.threads import Thread +from rattail.util import simple_error +import colander +from deform import widget as dfwidget from mako.template import Template from pyramid.response import Response +from webhelpers2.html import HTML, tags 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})$') weighted_upc_pattern = re.compile(r'^002(\d{5})00000\d$') +log = logging.getLogger(__name__) + def get_upc(product): """ UPC formatter. Strips PLUs to bare number, and adds "minus check digit" for non-PLU UPCs. """ - upc = unicode(product.upc) + upc = str(product.upc) m = plu_upc_pattern.match(upc) if m: - return unicode(int(m.group(1))) + return str(int(m.group(1))) m = weighted_upc_pattern.match(upc) if m: - return unicode(int(m.group(1))) + return str(int(m.group(1))) return '{0}-{1}'.format(upc[:-1], upc[-1]) @@ -72,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' @@ -95,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) @@ -118,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, @@ -127,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) @@ -147,16 +158,16 @@ class InventoryWorksheet(View): """ This is the "Inventory Worksheet" report. """ - + model = self.model departments = Session.query(model.Department) if self.request.params.get('department'): department = departments.get(self.request.params['department']) if department: body = self.write_report(department) - response = Response(content_type=b'text/html') - response.headers[b'Content-Length'] = len(body) - response.headers[b'Content-Disposition'] = b'attachment; filename=inventory.html' + response = Response(content_type=str('text/html')) + response.headers[str('Content-Length')] = len(body) + response.headers[str('Content-Disposition')] = str('attachment; filename=inventory.html') response.text = body return response @@ -168,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) @@ -181,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'), @@ -199,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', @@ -212,46 +230,625 @@ class ReportOutputView(ExportMasterView): 'created_by', ] + form_fields = [ + 'id', + 'report_name', + 'report_type', + 'params', + 'filename', + 'created', + '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 _preconfigure_fieldset(self, fs): - super(ReportOutputView, self)._preconfigure_fieldset(fs) - if self.viewing: - report = self.get_instance() - download = forms.renderers.FileFieldRenderer.new(self, - storage_path=report.filepath(self.rattail_config, filename=None), - file_path=report.filepath(self.rattail_config)) - fs.filename.set(renderer=download) + def configure_form(self, f): + super().configure_form(f) - def configure_fieldset(self, fs): - fs.configure(include=[ - fs.id, - fs.report_name, - fs.filename, - fs.created, - fs.created_by, - ]) + # report_type + f.set_renderer('report_type', self.render_report_type) + + # params + f.set_renderer('params', self.render_params) + + 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 + if not params: + return "" + + params = [{'key': key, 'value': value} + for key, value in params.items()] + # TODO: should sort these according to true Report definition instead? + params.sort(key=lambda param: param['key']) + + route_prefix = self.get_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_table_element(data_prop='paramsData')) + + 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 template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + output = kwargs['instance'] + + kwargs['params_data'] = self.get_params_context(output) + + # 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 + + return kwargs + + def template_kwargs_delete(self, **kwargs): + kwargs = super().template_kwargs_delete(**kwargs) + + 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. + """ + # handler is responsible for determining which report types are valid + reports = self.report_handler.get_reports() + if isinstance(reports, OrderedDict): + sorted_reports = list(reports) + else: + sorted_reports = sorted(reports, key=lambda k: reports[k].name) + + # 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) + form.submit_label = "Continue" + form.cancel_url = self.request.route_url('report_output') + + # TODO: should probably "group" certain reports together somehow? + # 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]) + 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(): + raise self.redirect(self.request.route_url('generate_specific_report', + type_key=form.validated['report_type'])) + + 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=False), + }) + + def generate(self): + """ + View for actually generating a new report. Allows user to provide + input parameters specific to the report type, then creates a new report + and redirects user to view the output. + """ + app = self.get_rattail_app() + type_key = self.request.matchdict['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, + datetime.date: colander.Date, + 'decimal': colander.Decimal, + } + + schema = colander.Schema() + helptext = {} + for param in report_params: + + # make a new node of appropriate schema type + node_type = NODE_TYPES.get(param.type, colander.String) + node = colander.SchemaNode(typ=node_type(), name=param.name) + + # maybe setup choices, if appropriate + if param.type == 'choice': + node.widget = dfwidget.SelectWidget( + values=report.get_choices(param.name, Session())) + + # allow empty value if param is optional + if not param.required: + 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, helptext=helptext) + form.submit_label = "Generate this 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? + for param in report_params: + 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(): + key = 'report_output.generate' + progress = self.make_progress(key) + kwargs = {'progress': progress} + thread = Thread(target=self.generate_thread, + args=(report, form.validated, self.request.user.uuid), + kwargs=kwargs) + thread.start() + return self.render_progress(progress, { + 'cancel_url': self.request.route_url('report_output'), + 'cancel_msg': "Report generation was canceled", + }) + + # 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(), + }) + + def generate_thread(self, report, params, user_uuid, progress=None): + """ + Generate output for the given report and params, and return the + resulting :class:`rattail:~rattail.db.model.reports.ReportOutput` + object. + """ + app = self.get_rattail_app() + model = self.model + session = app.make_session() + user = session.get(model.User, user_uuid) + try: + output = self.report_handler.generate_output(session, report, params, user, progress=progress) + + # if anything goes wrong, rollback and log the error etc. + except Exception as error: + session.rollback() + log.exception("Failed to generate '%s' report: %s", report.type_key, report) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Failed to generate report: {}".format( + simple_error(error)) + progress.session.save() + + # if no error, check result flag (false means user canceled) + else: + session.commit() + success_url = self.request.route_url('report_output.view', uuid=output.uuid) + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + 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) + + @classmethod + def _report_output_defaults(cls, config): + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # 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='{}.create'.format(permission_prefix)) + + +@colander.deferred +def valid_report_type(node, kw): + valid_report_types = kw['valid_report_types'] + + def validate(node, value): + # we just need to provide possible values, and let core validator + # handle the rest + oneof = colander.OneOf(valid_report_types) + return oneof(node, value) + + return validate + + +class NewReport(colander.Schema): + + report_type = colander.SchemaNode(colander.String(), + 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') + 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 c5e55765..e8a6d8a2 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.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,110 +24,544 @@ Role Views """ -from __future__ import unicode_literals, absolute_import +import os from sqlalchemy import orm +from openpyxl.styles import Font, PatternFill -from rattail.db import model -from rattail.db.auth import has_permission, administrator_role, guest_role, authenticated_role +from rattail.db.model import Role +from rattail.excel import ExcelWriter -import formalchemy as fa -from formalchemy.fields import IntegerFieldRenderer +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML -from tailbone import forms, grids +from tailbone import grids from tailbone.db import Session -from tailbone.views.principal import PrincipalMasterView +from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer -class RolesView(PrincipalMasterView): +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(RolesView, self).configure_grid(g) + super().configure_grid(g) + + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'name' + g.set_sort_defaults('name') g.set_link('name') - def _preconfigure_fieldset(self, fs): - fs.append(PermissionsField('permissions')) - permissions = self.request.registry.settings.get('tailbone_permissions', {}) - fs.permissions.set(renderer=forms.renderers.PermissionsFieldRenderer(permissions)) - fs.session_timeout.set(renderer=SessionTimeoutRenderer) - if (self.viewing or self.editing) and fs.model is guest_role(self.Session()): - fs.session_timeout.set(readonly=True, attrs={'applicable': False}) + # notes + g.set_renderer('notes', self.render_short_notes) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.name, - fs.session_timeout, - fs.permissions, - ]) + def render_short_notes(self, role, field): + value = getattr(role, field) + if value is None: + return "" + if len(value) < 100: + return value + return HTML.tag('span', title=value, + c="{}...".format(value[:100])) + + def editable_instance(self, role): + """ + We must prevent edit for certain built-in roles etc., depending on + current user's permissions. + """ + # role with node type specified, can only be edited from a + # node of the same type + 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 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 auth.get_role_authenticated(self.Session()): + return self.has_perm('edit_authenticated') + + # can edit Guest only if user has permission + 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 + user = self.request.user + if user and role in user.roles: + return self.has_perm('edit_my') + + return True + + def deletable_instance(self, role): + """ + We must prevent deletion for all built-in roles. + """ + # role with node type specified, can only be edited from a + # node of the same type + 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() + + if role is auth.get_role_administrator(self.Session()): + return False + if role is auth.get_role_authenticated(self.Session()): + return False + 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: + return self.has_perm('edit_my') + + 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: + role = self.get_instance() + query = query.filter(model.Role.uuid != role.uuid) + if query.count(): + raise colander.Invalid(node, "Name must be unique") + + def configure_form(self, f): + super().configure_form(f) + role = f.model_instance + 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 auth.get_role_anonymous(self.Session()): + f.set_readonly('session_timeout') + + # sync_me, node_type + if not self.creating: + include = True + if role is auth.get_role_administrator(self.Session()): + include = False + elif role is auth.get_role_authenticated(self.Session()): + include = False + elif role is auth.get_role_anonymous(self.Session()): + include = False + if not include: + 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 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(request=self.request, + permissions=self.tailbone_permissions)) + f.set_node('permissions', colander.Set()) + f.set_widget('permissions', PermissionsWidget( + permissions=self.tailbone_permissions)) + if self.editing: + granted = [] + for groupkey in self.tailbone_permissions: + for key in self.tailbone_permissions[groupkey]['perms']: + if auth.has_permission(self.Session(), role, key, + include_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 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 auth.get_role_authenticated(self.Session()): + return ("The authenticated role is implied for all users, " + "but only when logged in.") + + 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}.users', + data=[], + columns=[ + 'full_name', + 'username', + 'active', + ], + sortable=True, + sorters={'full_name': True, 'username': True, 'active': True}, + default_sortkey='full_name', + ) + + if self.request.has_perm('users.view'): + g.actions.append(self.make_action('view', icon='eye')) + if self.request.has_perm('users.edit'): + g.actions.append(self.make_action('edit', icon='edit')) + + return HTML.literal( + g.render_table_element(data_prop='usersData')) + + def get_available_permissions(self): + """ + Should return a dictionary with all "available" permissions. The + result of this will vary depending on the current user, because a user + is only allowed to "manage" permissions which they themselves have been + granted. + + In other words the return value will be the "full set" of permissions, + if the current user is an admin; otherwise it will be the "subset" of + permissions which the current user has been granted. + """ + # 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: + return permissions + + # when viewing, we allow all permissions to be exposed for all users + if self.viewing: + return permissions + + # non-admin user can only manage permissions they've been granted + # 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 permissions.items(): + for pkey, perm in group['perms'].items(): + if self.request.has_perm(pkey): + if gkey not in available: + available[gkey] = { + 'key': gkey, + 'label': group['label'], + 'perms': {}, + } + available[gkey]['perms'][pkey] = perm + return available + + def render_session_timeout(self, role, field): + 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 str(role.session_timeout) + + def objectify(self, form, data=None): + """ + Supplements the default logic, as follows: + + The role is updated as per usual, and then we also invoke + :meth:`update_permissions()` in order to correctly handle that part, + i.e. ensure the user can't modify permissions which they do not have. + """ + if data is None: + data = form.validated + role = super().objectify(form, data) + self.update_permissions(role, data['permissions']) + return role + + def update_permissions(self, role, permissions): + """ + Update the given role's permissions, according to those specified. + Note that this will not simply "clobber" the role's existing + 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 available.items(): + for pkey, perm in group['perms'].items(): + if pkey in permissions: + auth.grant_permission(role, pkey) + else: + auth.revoke_permission(role, pkey) def template_kwargs_view(self, **kwargs): + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = self.model role = kwargs['instance'] if role.users: users = sorted(role.users, key=lambda u: u.username) actions = [ - grids.GridAction('view', icon='zoomin', + self.make_action('view', icon='zoomin', url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid)) ] - kwargs['users'] = grids.Grid(None, users, ['username'], request=self.request, model_class=model.User, - main_actions=actions) + kwargs['users'] = grids.Grid(self.request, + data=users, + columns=['username', 'active'], + model_class=model.User, + actions=actions) else: kwargs['users'] = None - kwargs['guest_role'] = guest_role(Session()) - kwargs['authenticated_role'] = authenticated_role(Session()) + + kwargs['guest_role'] = auth.get_role_anonymous(self.Session()) + kwargs['authenticated_role'] = auth.get_role_authenticated(self.Session()) + + 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(Session()) - guest = guest_role(Session()) - authenticated = authenticated_role(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): + 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') -class SessionTimeoutRenderer(IntegerFieldRenderer): + def find_by_perm_normalize(self, role): + data = super().find_by_perm_normalize(role) - def render_readonly(self, **kwargs): - if not kwargs.pop('applicable', True): - return "(not applicable)" - return super(SessionTimeoutRenderer, self).render_readonly(**kwargs) + 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() + + permissions = self.get_available_permissions() + + # prep the excel writer + path = os.path.join(self.rattail_config.workdir(), + 'permissions-matrix.xlsx') + writer = ExcelWriter(path, None) + sheet = writer.sheet + + # write header + sheet.cell(row=1, column=1, value="") + for i, role in enumerate(roles, 2): + sheet.cell(row=1, column=i, value=role.name) + + # font and fill pattern for permission group rows + bold = Font(bold=True) + group_fill = PatternFill(patternType='solid', + fgColor='d9d9d9', + bgColor='d9d9d9') + + # now we'll write the rows + writing_row = 2 + for groupkey in sorted(permissions, key=lambda k: permissions[k]['label'].lower()): + group = permissions[groupkey] + + # group label is bold, with fill pattern + cell = sheet.cell(row=writing_row, column=1, value=group['label']) + cell.font = bold + cell.fill = group_fill + + # continue fill pattern for rest of group row + for col, role in enumerate(roles, 2): + cell = sheet.cell(row=writing_row, column=col) + cell.fill = group_fill + + # okay, that row is done + writing_row += 1 + + # now we list each perm in the group + perms = group['perms'] + for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): + sheet.cell(row=writing_row, column=1, value=perms[key]['label']) + + # and show an 'X' for any role which has this perm + for col, role in enumerate(roles, 2): + if auth.has_permission(self.Session(), role, key, + include_anonymous=False): + sheet.cell(row=writing_row, column=col, value="X") + + writing_row += 1 + + writer.auto_resize() + writer.auto_freeze(column=2) + writer.save() + return self.file_response(path) + + @classmethod + def defaults(cls, config): + cls._principal_defaults(config) + cls._role_defaults(config) + cls._defaults(config) + + @classmethod + def _role_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() + + # extra permissions for editing built-in roles etc. + config.add_tailbone_permission(permission_prefix, '{}.edit_authenticated'.format(permission_prefix), + "Edit the \"Authenticated\" Role") + config.add_tailbone_permission(permission_prefix, '{}.edit_guest'.format(permission_prefix), + "Edit the \"Guest\" Role") + config.add_tailbone_permission(permission_prefix, '{}.edit_my'.format(permission_prefix), + "Edit Role(s) to which current user belongs") + config.add_tailbone_permission(permission_prefix, + '{}.edit_node_sync'.format(permission_prefix), + "Edit the Node Type and Sync flags for a {}".format(model_title)) + + # download permissions matrix + config.add_tailbone_permission(permission_prefix, '{}.download_permissions_matrix'.format(permission_prefix), + "Download complete Role/Permissions matrix") + config.add_route('{}.download_permissions_matrix'.format(route_prefix), '{}/permissions-matrix'.format(url_prefix), + request_method='GET') + config.add_view(cls, attr='download_permissions_matrix', route_name='{}.download_permissions_matrix'.format(route_prefix), + permission='{}.download_permissions_matrix'.format(permission_prefix)) + +# TODO: deprecate / remove this +RolesView = RoleView -class PermissionsField(fa.Field): - """ - Custom field for role permissions. - """ +class PermissionsWidget(dfwidget.Widget): + template = 'permissions' + permissions = None + true_val = 'true' - def sync(self): - if not self.is_readonly(): - role = self.model - role.permissions = self.renderer.deserialize() + def deserialize(self, field, pstruct): + return [key for key, val in pstruct.items() + if val == self.true_val] + + def get_checked_value(self, cstruct, value): + if cstruct is colander.null: + return False + return value in cstruct + + def serialize(self, field, cstruct, **kw): + kw.setdefault('permissions', self.permissions) + template = self.template + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + + +def defaults(config, **kwargs): + base = globals() + + RoleView = kwargs.get('RoleView', base['RoleView']) + RoleView.defaults(config) def includeme(config): - RolesView.defaults(config) + defaults(config) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index b15cac87..10a0c2eb 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.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,22 +24,177 @@ Settings Views """ -from __future__ import unicode_literals, absolute_import - +import json import re -from rattail.db import model - import colander -from tailbone.views import MasterView3 as MasterView +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 SettingsView(MasterView): +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 feedback = re.compile(r'^rattail\.mail\.user_feedback\..*') grid_columns = [ @@ -48,19 +203,20 @@ class SettingsView(MasterView): ] def configure_grid(self, g): - super(SettingsView, self).configure_grid(g) + super().configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'name' + g.set_sort_defaults('name') g.set_link('name') def configure_form(self, f): - super(SettingsView, 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") @@ -69,11 +225,249 @@ class SettingsView(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 + + +class AppSettingsForm(forms.Form): + + def get_label(self, key): + return self.labels.get(key, key) + + +class AppSettingsView(View): + """ + Core view which exposes "app settings" - aka. admin-friendly settings with + descriptions and type-specific form controls etc. + """ + + def __call__(self): + settings = sorted(self.iter_known_settings(), + key=lambda setting: (setting.group, + setting.namespace, + setting.name)) + groups = sorted(set([setting.group for setting in settings])) + current_group = None + + form = self.make_form(settings) + form.cancel_url = self.request.current_route_url() + if form.validate(): + self.save_form(form) + group = self.request.POST.get('settings-group') + if group is not None: + self.request.session['appsettings.current_group'] = group + self.request.session.flash("App Settings have been saved.") + return self.redirect(self.request.current_route_url()) + + if self.request.method == 'POST': + current_group = self.request.POST.get('settings-group') + + if not current_group: + current_group = self.request.session.get('appsettings.current_group') + + 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, + 'config_options': config_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_settings_data(self, form, groups, settings): + dform = form.make_deform_form() + grouped = dict([(label, []) + for label in groups]) + + for setting in settings: + field = dform[setting.node_name] + s = { + 'field_name': field.name, + 'label': form.get_label(field.name), + 'data_type': setting.data_type.__name__, + 'choices': setting.choices, + 'helptext': form.render_helptext(field.name) if form.has_helptext(field.name) else None, + 'error': False, # nb. may set to True below + } + + # 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'] = 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 = [] + for label in groups: + group = {'label': label, 'settings': grouped[label]} + data.append(group) + + return data + + def make_form(self, known_settings): + schema = colander.MappingSchema() + helptext = {} + for setting in known_settings: + kwargs = { + 'name': setting.node_name, + 'default': self.get_setting_value(setting), + } + if kwargs['default'] is None or kwargs['default'] == '': + kwargs['default'] = colander.null + if not setting.required: + kwargs['missing'] = colander.null + if setting.choices: + kwargs['validator'] = colander.OneOf(setting.choices) + kwargs['widget'] = forms.widgets.JQuerySelectWidget( + values=[(val, val) for val in setting.choices]) + schema.add(colander.SchemaNode(self.get_node_type(setting), **kwargs)) + helptext[setting.node_name] = setting.__doc__.strip() + return AppSettingsForm(schema=schema, request=self.request, helptext=helptext) + + def get_node_type(self, setting): + if setting.data_type is bool: + return colander.Bool() + elif setting.data_type is int: + return colander.Integer() + return colander.String() + + def save_form(self, form): + for setting in self.iter_known_settings(): + value = form.validated[setting.node_name] + if value is colander.null: + value = '' + self.save_setting_value(setting, value) + + def iter_known_settings(self): + """ + Iterate over all known 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, 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 + + def get_node_name(self, setting): + return '[{}] {}'.format(setting.namespace, setting.name) + + def get_setting_value(self, setting): + if setting.data_type is bool: + return self.rattail_config.getbool(setting.namespace, setting.name) + if setting.data_type is list: + return '\n'.join( + self.rattail_config.getlist(setting.namespace, setting.name, + default=[])) + return self.rattail_config.get(setting.namespace, setting.name) + + def save_setting_value(self, setting, value): + existing = self.get_setting_value(setting) + if existing != value: + legacy_name = '{}.{}'.format(setting.namespace, setting.name) + if setting.data_type is bool: + value = 'true' if value else 'false' + elif setting.data_type is list: + entries = [self.clean_list_entry(entry) + for entry in value.split('\n')] + value = ', '.join(entries) + else: + value = str(value) + app = self.get_rattail_app() + app.save_setting(Session(), legacy_name, value) + + def clean_list_entry(self, value): + value = value.strip() + if '"' in value and "'" in value: + raise NotImplementedError("don't know how to handle escaping 2 " + "different types of quotes!") + if '"' in value: + return "'{}'".format(value) + if "'" in value: + return '"{}"'.format(value) + return value + + @classmethod + def defaults(cls, config): + config.add_route('appsettings', '/settings/app/') + config.add_view(cls, route_name='appsettings', + renderer='/appsettings.mako', + permission='settings.edit') + + +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): - SettingsView.defaults(config) + defaults(config) diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index ce9f4350..53bfc446 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,44 +24,32 @@ Views for employee shifts """ -from __future__ import unicode_literals, absolute_import - import datetime -import humanize - from rattail.db import model from rattail.time import localtime +from rattail.util import hours_as_decimal -import formalchemy +from webhelpers2.html import tags, HTML -from tailbone import forms -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView -class ShiftLengthField(formalchemy.Field): +class ShiftViewMixin: - def __init__(self, name, **kwargs): - kwargs.setdefault('value', self.shift_length) - super(ShiftLengthField, self).__init__(name, **kwargs) - - def shift_length(self, shift): + def render_shift_length(self, shift, field): if not shift.start_time or not shift.end_time: - return + return "" if shift.end_time < shift.start_time: return "??" - return humanize.naturaldelta(shift.end_time - shift.start_time) + 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)]) -def render_shift_length(shift, column): - if not shift.start_time or not shift.end_time: - return "" - if shift.end_time < shift.start_time: - return "??" - return humanize.naturaldelta(shift.end_time - shift.start_time) - - -class ScheduledShiftsView(MasterView): +class ScheduledShiftView(MasterView, ShiftViewMixin): """ Master view for employee scheduled shifts. """ @@ -76,36 +64,41 @@ class ScheduledShiftsView(MasterView): 'length', ] + form_fields = [ + 'employee', + 'store', + 'start_time', + 'end_time', + 'length', + ] + def configure_grid(self, g): g.joiners['employee'] = lambda q: q.join(model.Employee).join(model.Person) g.filters['employee'] = g.make_filter('employee', model.Person.display_name, default_active=True, default_verb='contains') - g.default_sortkey = 'start_time' - g.default_sortdir = 'desc' + 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_fieldset(self, fs): - fs.append(ShiftLengthField('length')) - fs.configure( - include=[ - fs.employee, - fs.store, - fs.start_time, - fs.end_time, - fs.length, - ]) + def configure_form(self, f): + super().configure_form(f) + + f.set_renderer('length', self.render_shift_length) + +# TODO: deprecate / remove this +ScheduledShiftsView = ScheduledShiftView -class WorkedShiftsView(MasterView): +class WorkedShiftView(MasterView, ShiftViewMixin): """ Master view for employee worked shifts. """ model_class = model.WorkedShift url_prefix = '/shifts/worked' + results_downloadable_xlsx = True has_versions = True grid_columns = [ @@ -116,24 +109,38 @@ class WorkedShiftsView(MasterView): 'length', ] + form_fields = [ + 'employee', + 'store', + 'start_time', + 'end_time', + 'length', + ] + def configure_grid(self, g): - super(WorkedShiftsView, 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.default_sortkey = 'start_time' - g.default_sortdir = 'desc' + 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") @@ -145,23 +152,74 @@ class WorkedShiftsView(MasterView): date = localtime(self.rattail_config, time).date() return "WorkedShift: {}, {}".format(shift.employee, date) - def _preconfigure_fieldset(self, fs): - fs.append(ShiftLengthField('length')) - fs.employee.set(readonly=True, renderer=forms.renderers.EmployeeFieldRenderer) + def configure_form(self, f): + super().configure_form(f) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.employee, - fs.store, - fs.start_time, - fs.end_time, - fs.length, - ]) + f.set_readonly('employee') + f.set_renderer('employee', self.render_employee) + + f.set_renderer('length', self.render_shift_length) if self.editing: - del fs.length + f.remove('length') + + def render_employee(self, shift, field): + employee = shift.employee + if not employee: + return "" + 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().get_xlsx_fields() + + # add employee name + i = fields.index('employee_uuid') + fields.insert(i + 1, 'employee_name') + + # add hours + fields.append('hours') + + return fields + + def get_xlsx_row(self, shift, fields): + row = super().get_xlsx_row(shift, fields) + + # localize start and end times (Excel requires time with no zone) + if shift.punch_in: + row['punch_in'] = localtime(self.rattail_config, shift.punch_in, from_utc=True, tzinfo=False) + if shift.punch_out: + row['punch_out'] = localtime(self.rattail_config, shift.punch_out, from_utc=True, tzinfo=False) + + # add employee name + row['employee_name'] = shift.employee.person.display_name + + # add hours + if shift.punch_in and shift.punch_out: + if shift.punch_in <= shift.punch_out: + row['hours'] = hours_as_decimal(shift.punch_out - shift.punch_in, places=4) + else: + row['hours'] = "??" + elif shift.punch_in or shift.punch_out: + row['hours'] = "??" + else: + row['hours'] = None + + return row + +# TODO: deprecate / remove this +WorkedShiftsView = WorkedShiftView + + +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): - ScheduledShiftsView.defaults(config) - WorkedShiftsView.defaults(config) + defaults(config) diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index c2f24001..1827bee0 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.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,20 +24,16 @@ 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 formencode as fe -from pyramid_simpleform import Form +import colander +from deform import widget as dfwidget from webhelpers2.html import tags, HTML from tailbone import forms @@ -45,19 +41,20 @@ from tailbone.db import Session from tailbone.views import View -class ShiftFilter(fe.Schema): - allow_extra_fields = True - filter_extra_fields = True - store = forms.validators.ValidStore() - department = forms.validators.ValidDepartment() - date = fe.validators.DateConverter() +class ShiftFilter(colander.Schema): + + store = colander.SchemaNode(forms.types.StoreType()) + + department = colander.SchemaNode(forms.types.DepartmentType()) + + date = colander.SchemaNode(colander.Date()) -class EmployeeShiftFilter(fe.Schema): - allow_extra_fields = True - filter_extra_fields = True - employee = forms.validators.ValidEmployee() - date = fe.validators.DateConverter() +class EmployeeShiftFilter(colander.Schema): + + employee = colander.SchemaNode(forms.types.EmployeeType()) + + date = colander.SchemaNode(colander.Date()) class TimeSheetView(View): @@ -85,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: @@ -95,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 @@ -104,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') @@ -115,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) @@ -134,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: @@ -144,21 +145,21 @@ 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 # force current user if not allowed to view all data if not self.request.has_perm('{}.viewall'.format(self.key)): employee = self.request.user.employee - assert employee + # note that employee may still be None, e.g. if current user is not employee return {'date': date, 'employee': employee} def process_filter_form(self, form): @@ -166,47 +167,100 @@ 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 self.request.method == 'POST': - if form.validate(): - store = form.data['store'] - self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None - department = form.data['department'] - self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None - date = form.data['date'] - self.request.session['timesheet.{}.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None - raise self.redirect(self.request.current_route_url()) + 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'] + self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None + date = form.validated['date'] + self.request.session['timesheet.{}.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None + raise self.redirect(self.request.current_route_url()) def process_employee_filter_form(self, form): """ 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 self.request.method == 'POST': - if form.validate(): - employee = form.data['employee'] - self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None - date = form.data['date'] - self.request.session['timesheet.{}.employee.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None - raise self.redirect(self.request.current_route_url()) + 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'] + self.request.session['timesheet.{}.employee.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None + raise self.redirect(self.request.current_route_url()) + + def make_full_filter_form(self, context): + form = forms.Form(schema=ShiftFilter(), request=self.request) + + 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', dfwidget.SelectWidget(values=store_values)) + if context['store']: + form.set_default('store', context['store'].uuid) + else: + # TODO: why is this necessary? somehow the previous store is being + # preserved as the "default" when switching from single store view + # to "all stores" view + form.set_default('store', '') + + departments = self.get_departments() + department_values = [(d.uuid, d.name) for d in departments] + department_values.insert(0, ('', "(all)")) + form.set_widget('department', dfwidget.SelectWidget(values=department_values)) + if context['department']: + form.set_default('department', context['department'].uuid) + else: + # TODO: why is this necessary? somehow the previous dept is being + # preserved as the "default" when switching from single dept view + # to "all depts" view + form.set_default('department', '') + + form.set_type('date', 'date_jquery') + form.set_default('date', get_sunday(context['date'])) + return form def full(self): """ View a "full" timesheet/schedule, i.e. all employees but filterable by store and/or department. """ - form = Form(self.request, schema=ShiftFilter) - self.process_filter_form(form) context = self.get_timesheet_context() + form = self.make_full_filter_form(context) + self.process_filter_form(form) context['form'] = form return self.render_full(**context) + def make_employee_filter_form(self, context): + """ + View time sheet for single employee. + """ + permission_prefix = self.key + form = forms.Form(schema=EmployeeShiftFilter(), request=self.request) + + if self.request.has_perm('{}.viewall'.format(permission_prefix)): + 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)) + if context['employee']: + form.set_default('employee', context['employee'].uuid) + else: + form.set_widget('employee', forms.widgets.ReadonlyWidget()) + form.set_default('employee', context['employee'].uuid) + + form.set_type('date', 'date_jquery') + form.set_default('date', get_sunday(context['date'])) + return form + def employee(self): """ View time sheet for single employee. """ - form = Form(self.request, schema=EmployeeShiftFilter) - self.process_employee_filter_form(form) context = self.get_employee_context() + if not context['employee']: + raise self.notfound() + form = self.make_employee_filter_form(context) + self.process_employee_filter_form(form) context['form'] = form return self.render_single(**context) @@ -241,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): @@ -248,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): @@ -280,7 +336,8 @@ class TimeSheetView(View): context = { 'page_title': self.get_title_full(), - 'form': forms.FormRenderer(form) if form else None, + 'form': form, + 'dform': form.make_deform_form() if form else None, 'employees': employees, 'stores': stores, 'store_options': store_options, @@ -324,8 +381,10 @@ class TimeSheetView(View): self.modify_employees([employee], weekdays) context = { + 'single': True, 'page_title': "Employee {}".format(self.get_title()), - 'form': forms.FormRenderer(form) if form else None, + 'form': form, + 'dform': form.make_deform_form() if form else None, 'employee': employee, 'employees': [employee], 'week_of': week_of, @@ -347,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' @@ -357,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() @@ -387,6 +449,7 @@ class TimeSheetView(View): '{}_shifts'.format(shift_type): [], '{}_hours'.format(shift_type): datetime.timedelta(0), '{}_hours_display'.format(shift_type): '', + 'hours_incomplete': False, } while employee_shifts: @@ -402,6 +465,7 @@ class TimeSheetView(View): getattr(employee, '{}_hours'.format(shift_type)) + shift.length) else: hours_incomplete = True + empday['hours_incomplete'] = True del employee_shifts[0] else: break @@ -409,17 +473,20 @@ class TimeSheetView(View): hours = empday['{}_hours'.format(shift_type)] if hours: if hours_style == 'pretty': - empday['{}_hours_display'.format(shift_type)] = pretty_hours(hours) + display = app.render_duration(hours=hours) else: # decimal - empday['{}_hours_display'.format(shift_type)] = 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 employee.weekdays[i].update(empday) 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 a8113f9f..c8b82724 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.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. # @@ -24,17 +24,13 @@ Views for employee schedules """ -from __future__ import unicode_literals, absolute_import - import datetime from rattail.db import model from rattail.time import localtime, make_utc, get_sunday -from pyramid_simpleform import Form - from tailbone.db import Session -from tailbone.views.shifts.lib import TimeSheetView, ShiftFilter +from tailbone.views.shifts.lib import TimeSheetView class ScheduleView(TimeSheetView): @@ -61,10 +57,9 @@ class ScheduleView(TimeSheetView): return self.redirect(self.request.route_url('schedule.edit')) # okay then, process filters; redirect if any were received - form = Form(self.request, schema=ShiftFilter) - self.process_filter_form(form) - context = self.get_timesheet_context() + form = self.make_full_filter_form(context) + self.process_filter_form(form) # okay then, maybe process saved shift data if self.request.method == 'POST': @@ -82,9 +77,9 @@ class ScheduleView(TimeSheetView): # apply delete operations deleted = [] - for uuid, value in data['delete'].iteritems(): + 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) @@ -93,7 +88,7 @@ class ScheduleView(TimeSheetView): created = {} updated = {} time_format = '%a %d %b %Y %I:%M %p' - for uuid, employee_uuid in data['start_time'].iteritems(): + for uuid, employee_uuid in data['start_time'].items(): if uuid in deleted: continue if uuid.startswith('new-'): @@ -106,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) @@ -199,13 +194,28 @@ class ScheduleView(TimeSheetView): permission='schedule.edit') config.add_tailbone_permission('schedule', 'schedule.edit', "Edit full schedule") - # print schedule + # printing "any" schedule requires this permission + config.add_tailbone_permission('schedule', 'schedule.print', "Print schedule") + + # print full schedule config.add_route('schedule.print', '/schedule/print') config.add_view(cls, attr='full', route_name='schedule.print', renderer='/shifts/schedule_print.mako', permission='schedule.print') - config.add_tailbone_permission('schedule', 'schedule.print', "Print schedule") + + # print employee schedule + config.add_route('schedule.employee.print', '/schedule/employee/print') + config.add_view(cls, attr='employee', route_name='schedule.employee.print', + renderer='/shifts/schedule_print_employee.mako', + permission='schedule.print') + + +def defaults(config, **kwargs): + base = globals() + + ScheduleView = kwargs.get('ScheduleView', base['ScheduleView']) + ScheduleView.defaults(config) def includeme(config): - ScheduleView.defaults(config) + defaults(config) diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index b62b9bd4..a8874127 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.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. # @@ -24,17 +24,13 @@ Views for employee time sheets """ -from __future__ import unicode_literals, absolute_import - import datetime from rattail.db import model from rattail.time import make_utc, localtime -from pyramid_simpleform import Form - from tailbone.db import Session -from tailbone.views.shifts.lib import TimeSheetView as BaseTimeSheetView, EmployeeShiftFilter +from tailbone.views.shifts.lib import TimeSheetView as BaseTimeSheetView class TimeSheetView(BaseTimeSheetView): @@ -50,10 +46,11 @@ class TimeSheetView(BaseTimeSheetView): View for editing single employee's timesheet """ # process filters; redirect if any were received - form = Form(self.request, schema=EmployeeShiftFilter) - self.process_employee_filter_form(form) - context = self.get_employee_context() + if not context['employee']: + raise self.notfound() + form = self.make_employee_filter_form(context) + self.process_employee_filter_form(form) # okay then, maybe process saved shift data if self.request.method == 'POST': @@ -75,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) @@ -84,7 +81,7 @@ class TimeSheetView(BaseTimeSheetView): created = {} updated = {} time_format = '%a %d %b %Y %I:%M %p' - for uuid, time in data['start_time'].iteritems(): + for uuid, time in data['start_time'].items(): if uuid in deleted: continue if uuid.startswith('new-'): @@ -94,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 @@ -134,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 143aa467..5d507745 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2022 Lance Edgar # # This file is part of Rattail. # @@ -32,15 +32,17 @@ from rattail.db import model import colander -from tailbone.views import MasterView3 as MasterView +from tailbone import grids +from tailbone.views import MasterView -class StoresView(MasterView): +class StoreView(MasterView): """ Master view for the Store class. """ model_class = model.Store has_versions = True + touchable = True grid_columns = [ 'id', @@ -49,6 +51,15 @@ class StoresView(MasterView): 'email', ] + form_fields = [ + 'id', + 'name', + 'phone', + 'email', + 'database_key', + 'archived', + ] + labels = { 'id': "ID", 'phone': "Phone Number", @@ -56,7 +67,7 @@ class StoresView(MasterView): } def configure_grid(self, g): - super(StoresView, self).configure_grid(g) + super(StoreView, self).configure_grid(g) g.set_joiner('email', lambda q: q.outerjoin(model.StoreEmailAddress, sa.and_( model.StoreEmailAddress.parent_uuid == model.Store.uuid, @@ -65,20 +76,29 @@ class StoresView(MasterView): model.StorePhoneNumber.parent_uuid == model.Store.uuid, model.StorePhoneNumber.preference == 1))) - g.filters['phone'] = g.make_filter('phone', model.StorePhoneNumber.number) + g.set_filter('phone', model.StorePhoneNumber.number, + factory=grids.filters.AlchemyPhoneNumberFilter) g.filters['email'] = g.make_filter('email', model.StoreEmailAddress.address) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' + # archived + g.filters['archived'].default_active = True + g.filters['archived'].default_verb = 'is_false_null' + g.set_sorter('phone', model.StorePhoneNumber.number) g.set_sorter('email', model.StoreEmailAddress.address) - g.default_sortkey = 'id' + g.set_sort_defaults('id') g.set_link('id') g.set_link('name') + def grid_extra_class(self, store, i): + if store.archived: + return 'warning' + def configure_form(self, f): - super(StoresView, self).configure_form(f) + super(StoreView, self).configure_form(f) f.remove_field('employees') f.remove_field('phones') @@ -97,6 +117,16 @@ class StoresView(MasterView): (model.StoreEmailAddress, 'parent_uuid'), ] +# TODO: deprecate / remove this +StoresView = StoreView + + +def defaults(config, **kwargs): + base = globals() + + StoreView = kwargs.get('StoreView', base['StoreView']) + StoreView.defaults(config) + def includeme(config): - StoresView.defaults(config) + defaults(config) diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index c6bf449c..43648ea6 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,19 +24,24 @@ 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 MasterView3 as MasterView +from tailbone.views import MasterView -class SubdepartmentsView(MasterView): +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 = [ @@ -45,6 +50,12 @@ class SubdepartmentsView(MasterView): 'department', ] + form_fields = [ + 'number', + 'name', + 'department', + ] + mergeable = True merge_additive_fields = [ 'product_count', @@ -56,21 +67,73 @@ class SubdepartmentsView(MasterView): 'department_number', ] + has_rows = True + model_row_class = model.Product + + row_labels = { + 'upc': "UPC", + } + + row_grid_columns = [ + 'upc', + 'brand', + 'description', + 'size', + 'vendor', + 'regular_price', + 'current_price', + ] + def configure_grid(self, g): - super(SubdepartmentsView, self).configure_grid(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' - g.default_sortkey = 'name' - g.set_link('number') + g.set_sort_defaults('name') + + # department (name) + g.set_joiner('department', lambda q: q.outerjoin(model.Department)) + g.set_sorter('department', model.Department.name) + g.set_filter('department', model.Department.name) + g.set_link('name') def configure_form(self, f): - super(SubdepartmentsView, self).configure_form(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 { @@ -89,6 +152,43 @@ class SubdepartmentsView(MasterView): Session.delete(removing) + def get_row_data(self, subdepartment): + return self.Session.query(model.Product)\ + .filter(model.Product.subdepartment == subdepartment) + + def get_parent(self, product): + return product.subdepartment + + def configure_row_grid(self, g): + super(SubdepartmentView, self).configure_row_grid(g) + + app = self.get_rattail_app() + self.handler = app.get_products_handler() + g.set_renderer('regular_price', self.render_price) + g.set_renderer('current_price', self.render_price) + + g.set_sort_defaults('upc') + + def render_price(self, product, field): + if not product.not_for_sale: + price = product[field] + if price: + return self.handler.render_price(price) + + def row_view_action_url(self, product, i): + return self.request.route_url('products.view', uuid=product.uuid) + + +# TODO: deprecate / remove this +SubdepartmentsView = SubdepartmentView + + +def defaults(config, **kwargs): + base = globals() + + SubdepartmentView = kwargs.get('SubdepartmentView', base['SubdepartmentView']) + SubdepartmentView.defaults(config) + def includeme(config): - SubdepartmentsView.defaults(config) + defaults(config) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py index f1e75eb0..bfd52f2b 100644 --- a/tailbone/views/tables.py +++ b/tailbone/views/tables.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,47 +24,436 @@ Views with info about the underlying Rattail tables """ -from __future__ import unicode_literals, absolute_import +import os +import sys +import warnings -from tailbone.views import MasterView2 as MasterView +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 -class TablesView(MasterView): +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 """ + # note that we only show 'public' schema tables, i.e. avoid the 'batch' + # schema, at least for now? maybe should include all, plus show the + # schema name within the results grid? sql = """ - select schemaname, relname, n_live_tup + select relname, n_live_tup from pg_stat_user_tables + where schemaname = 'public' order by n_live_tup desc; """ - result = self.Session.execute(sql) - return [dict(name=row[1], row_count=row[2]) for row in result] + result = self.Session.execute(sa.text(sql)) + return [dict(table_name=row.relname, row_count=row.n_live_tup) + for row in result] def configure_grid(self, g): - g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) + super().configure_grid(g) + + # table_name + g.sorters['table_name'] = g.make_simple_sorter('table_name', foldcase=True) + g.set_sort_defaults('table_name') + g.set_searchable('table_name') + g.set_link('table_name') + + # row_count g.sorters['row_count'] = g.make_simple_sorter('row_count') - g.default_sortkey = 'name' + + 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): - TablesView.defaults(config) + defaults(config) diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index 75485342..b2afaeb9 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,14 +24,12 @@ Tax Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model -from tailbone.views import MasterView2 as MasterView +from tailbone.views import MasterView -class TaxesView(MasterView): +class TaxView(MasterView): """ Master view for taxes. """ @@ -46,22 +44,44 @@ class TaxesView(MasterView): 'rate', ] + form_fields = [ + 'code', + 'description', + 'rate', + ] + def configure_grid(self, g): - super(TaxesView, self).configure_grid(g) + 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' - g.default_sortkey = 'code' - g.set_link('code') - g.set_link('description') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.code, - fs.description, - fs.rate, - ]) + # 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 defaults(config, **kwargs): + base = globals() + + TaxView = kwargs.get('TaxView', base['TaxView']) + TaxView.defaults(config) def includeme(config): - TaxesView.defaults(config) + defaults(config) diff --git a/tailbone/views/tempmon/__init__.py b/tailbone/views/tempmon/__init__.py index 81e94211..840a38c1 100644 --- a/tailbone/views/tempmon/__init__.py +++ b/tailbone/views/tempmon/__init__.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -26,10 +26,12 @@ Views for tempmon from __future__ import unicode_literals, absolute_import -from .core import MasterView, ClientFieldRenderer, ProbeFieldRenderer +from .core import MasterView def includeme(config): + config.include('tailbone.views.tempmon.appliances') config.include('tailbone.views.tempmon.clients') config.include('tailbone.views.tempmon.probes') config.include('tailbone.views.tempmon.readings') + config.include('tailbone.views.tempmon.dashboard') diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py new file mode 100644 index 00000000..4ce52009 --- /dev/null +++ b/tailbone/views/tempmon/appliances.py @@ -0,0 +1,204 @@ +# -*- 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 tempmon appliances +""" + +import io +import os + +from PIL import Image + +from rattail_tempmon.db import model as tempmon + +import colander +from webhelpers2.html import HTML, tags + +from tailbone.views.tempmon import MasterView + + +class TempmonApplianceView(MasterView): + """ + Master view for tempmon appliances. + """ + model_class = tempmon.Appliance + model_title = "TempMon Appliance" + model_title_plural = "TempMon Appliances" + route_prefix = 'tempmon.appliances' + url_prefix = '/tempmon/appliances' + has_image = True + has_thumbnail = True + + grid_columns = [ + 'name', + 'appliance_type', + 'location', + 'image', + ] + + form_fields = [ + 'name', + 'appliance_type', + 'location', + 'clients', + 'probes', + 'image', + ] + + def configure_grid(self, g): + super().configure_grid(g) + + # name + g.set_sort_defaults('name') + g.set_link('name') + + # appliance_type + g.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) + g.set_label('appliance_type', "Type") + g.filters['appliance_type'].label = "Appliance Type" + + # location + g.set_link('location') + + # image + g.set_renderer('image', self.render_grid_thumbnail) + g.set_link('image') + + def render_grid_thumbnail(self, appliance, field): + route_prefix = self.get_route_prefix() + url = self.request.route_url('{}.thumbnail'.format(route_prefix), uuid=appliance.uuid) + image = tags.image(url, "") + helper = HTML.tag('span', class_='image-helper') + return HTML.tag('div', class_='image-frame', c=[helper, image]) + + def configure_form(self, f): + super().configure_form(f) + + # name + f.set_validator('name', self.unique_name) + + # appliance_type + f.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) + + # image + if self.creating or self.editing: + f.set_type('image', 'file') + f.set_required('image', False) + else: + f.set_renderer('image', self.render_image) + + # clients + if self.viewing: + f.set_renderer('clients', self.render_clients) + else: + f.remove_field('clients') + + # probes + if self.viewing: + f.set_renderer('probes', self.render_probes) + 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) + if self.editing: + appliance = self.get_instance() + query = query.filter(tempmon.Appliance.uuid != appliance.uuid) + if query.count(): + raise colander.Invalid(node, "Name must be unique") + + def get_image_bytes(self, appliance): + return appliance.image_normal or appliance.image_raw + + def get_thumbnail_bytes(self, appliance): + return appliance.image_thumbnail + + def render_image(self, appliance, field): + route_prefix = self.get_route_prefix() + url = self.request.route_url('{}.image'.format(route_prefix), uuid=appliance.uuid) + return tags.image(url, "Appliance Image", id='appliance-image') #, width=500) #, height=500) + + def render_clients(self, appliance, field): + clients = {} + for probe in appliance.probes: + if probe.client.uuid not in clients: + clients[probe.client.uuid] = probe.client + + if not clients: + return "" + + clients = sorted(clients.values(), key=lambda client: client.hostname) + items = [HTML.tag('li', c=[tags.link_to(client.hostname, self.request.route_url('tempmon.clients.view', uuid=client.uuid))]) + for client in clients] + return HTML.tag('ul', c=items) + + def process_uploads(self, appliance, form, uploads): + image = uploads.pop('image', None) + if image: + + # capture raw image as-is (note, this assumes jpeg) + with open(image['temp_path'], 'rb') as f: + appliance.image_raw = f.read() + + # resize image and store as separate attributes + with open(image['temp_path'], 'rb') as f: + im = Image.open(f) + + im.thumbnail((600, 600), Image.ANTIALIAS) + data = io.BytesIO() + im.save(data, 'JPEG') + appliance.image_normal = data.getvalue() + data.close() + + im.thumbnail((150, 150), Image.ANTIALIAS) + data = io.BytesIO() + im.save(data, 'JPEG') + appliance.image_thumbnail = data.getvalue() + data.close() + + # cleanup temp files + os.remove(image['temp_path']) + os.rmdir(image['tempdir']) + + if uploads: + raise NotImplementedError("too many uploads?") + + +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 2144cf14..1b2d49d8 100644 --- a/tailbone/views/tempmon/clients.py +++ b/tailbone/views/tempmon/clients.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,39 +24,17 @@ Views for tempmon clients """ -from __future__ import unicode_literals, absolute_import - import subprocess +from rattail.config import parse_list from rattail_tempmon.db import model as tempmon -import formalchemy as fa +import colander from webhelpers2.html import HTML, tags -from tailbone.db import TempmonSession +from tailbone import forms from tailbone.views.tempmon import MasterView - - -class ProbesFieldRenderer(fa.FieldRenderer): - - def render_readonly(self, **kwargs): - probes = self.raw_value - if not probes: - return '' - items = [] - for probe in probes: - items.append(HTML.tag('li', c=tags.link_to(probe, self.request.route_url('tempmon.probes.view', uuid=probe.uuid)))) - return HTML.tag('ul', c=items) - - -def unique_config_key(value, field): - client = field.parent.model - query = TempmonSession.query(tempmon.Client)\ - .filter(tempmon.Client.config_key == value) - if client.uuid: - query = query.filter(tempmon.Client.uuid != client.uuid) - if query.count(): - raise fa.ValidationError("Config key must be unique") +from tailbone.util import raw_datetime class TempmonClientView(MasterView): @@ -69,6 +47,10 @@ class TempmonClientView(MasterView): route_prefix = 'tempmon.clients' url_prefix = '/tempmon/clients' + has_rows = True + model_row_class = tempmon.Reading + rows_title = "Readings" + grid_columns = [ 'config_key', 'hostname', @@ -76,43 +58,153 @@ class TempmonClientView(MasterView): 'delay', 'enabled', 'online', + 'archived', + ] + + form_fields = [ + 'config_key', + 'hostname', + 'location', + 'disk_type', + 'delay', + 'appliances', + 'probes', + 'notes', + 'enabled', + 'online', + 'archived', + ] + + row_grid_columns = [ + 'probe', + 'degrees_f', + 'taken', ] def configure_grid(self, g): - super(TempmonClientView, self).configure_grid(g) + super().configure_grid(g) + + # config_key + g.set_label('config_key', "Key") + g.set_sort_defaults('config_key') + g.set_link('config_key') + + # hostname g.filters['hostname'].default_active = True g.filters['hostname'].default_verb = 'contains' + g.set_link('hostname') + + # location g.filters['location'].default_active = True g.filters['location'].default_verb = 'contains' - g.default_sortkey = 'config_key' - - g.set_type('enabled', 'boolean') - g.set_type('online', 'boolean') - - g.set_label('config_key', "Key") - - g.set_link('config_key') - g.set_link('hostname') g.set_link('location') - def _preconfigure_fieldset(self, fs): - fs.config_key.set(validate=unique_config_key) - fs.probes.set(renderer=ProbesFieldRenderer) + # disk_type + g.set_enum('disk_type', self.enum.TEMPMON_DISK_TYPE) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.config_key, - fs.hostname, - fs.location, - fs.delay, - fs.probes, - fs.enabled, - fs.online, - ]) + # enabled + g.set_renderer('enabled', self.render_enabled_grid) + + # archived + g.filters['archived'].default_active = True + g.filters['archived'].default_verb = 'is_false' + + def render_enabled_grid(self, client, field): + if client.enabled: + return "Yes" + return "No" + + def configure_form(self, f): + super().configure_form(f) + + # config_key + f.set_validator('config_key', self.unique_config_key) + + # disk_type + f.set_enum('disk_type', self.enum.TEMPMON_DISK_TYPE) + f.widgets['disk_type'].values.insert(0, ('', "(unknown)")) + + # delay + f.set_helptext('delay', tempmon.Client.delay.__doc__) + + # appliances + if self.viewing: + f.set_renderer('appliances', self.render_appliances) + else: + f.remove_field('appliances') + + # probes + if self.viewing: + f.set_renderer('probes', self.render_probes) + else: + f.remove_field('probes') + + # notes + f.set_type('notes', 'text') + + # enabled if self.creating or self.editing: - del fs.probes - del fs.online + f.set_node('enabled', forms.types.DateTimeBoolean()) + else: + f.set_renderer('enabled', self.render_enabled_form) + f.set_helptext('enabled', tempmon.Client.enabled.__doc__) + + # online + if self.creating or self.editing: + f.remove_field('online') + else: + f.set_helptext('online', tempmon.Client.online.__doc__) + + # 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 + # simple edits are being done to the client. i.e. we do want to set + # the timestamp when it was previously null, but not otherwise. + if self.editing: + data = dict(data or form.validated) + if data['enabled'] and form.model_instance.enabled: + data['enabled'] = form.model_instance.enabled + + return super().objectify(form, data=data) + + def unique_config_key(self, node, value): + query = self.Session.query(tempmon.Client)\ + .filter(tempmon.Client.config_key == value) + if self.editing: + client = self.get_instance() + query = query.filter(tempmon.Client.uuid != client.uuid) + if query.count(): + raise colander.Invalid(node, "Config key must be unique") + + def render_appliances(self, client, field): + appliances = {} + for probe in client.probes: + if probe.appliance and probe.appliance.uuid not in appliances: + appliances[probe.appliance.uuid] = probe.appliance + + if not appliances: + return "" + + appliances = sorted(appliances.values(), key=lambda a: a.name) + items = [HTML.tag('li', c=[tags.link_to(a.name, self.request.route_url('tempmon.appliances.view', uuid=a.uuid))]) + for a in appliances] + return HTML.tag('ul', c=items) + + def render_enabled_form(self, client, field): + if client.enabled: + return raw_datetime(self.rattail_config, client.enabled) + return "No" def delete_instance(self, client): # bulk-delete all readings first @@ -127,8 +219,27 @@ class TempmonClientView(MasterView): self.Session.delete(client) self.Session.flush() + def get_row_data(self, client): + query = self.Session.query(tempmon.Reading)\ + .join(tempmon.Probe)\ + .filter(tempmon.Reading.client == client) + return query + + def get_parent(self, reading): + return reading.client + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + # probe + g.set_filter('probe', tempmon.Probe.description) + g.set_sorter('probe', tempmon.Probe.description) + + g.set_sort_defaults('taken', 'desc') + def restartable_client(self, client): - return True + cmd = self.get_restart_cmd(client) + return bool(cmd) def restart(self): client = self.get_instance() @@ -146,7 +257,13 @@ class TempmonClientView(MasterView): return self.redirect(self.get_action_url('view', client)) def get_restart_cmd(self, client): - return ['ssh', client.hostname, 'sudo service tempmon-client restart'] + name = 'rattail.tempmon.client.restart' + cmd = self.rattail_config.get('rattail.tempmon', 'client.restart.{}'.format(client.config_key)) + if not cmd: + cmd = self.rattail_config.get('rattail.tempmon', 'client.restart') + if cmd: + cmd = cmd.format(hostname=client.hostname) + return parse_list(cmd) @classmethod def defaults(cls, config): @@ -166,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 545eb3cb..7540abbe 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.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,40 +24,79 @@ Common stuff for tempmon views """ -from __future__ import unicode_literals, absolute_import +from webhelpers2.html import HTML -from rattail_tempmon.db import Session as RawTempmonSession - -from formalchemy.fields import SelectFieldRenderer -from webhelpers2.html import tags - -from tailbone import views +from tailbone import views, grids from tailbone.db import TempmonSession -class MasterView(views.MasterView2): +class MasterView(views.MasterView): """ Base class for tempmon views. """ Session = TempmonSession def get_bulk_delete_session(self): - return RawTempmonSession() + 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 -class ClientFieldRenderer(SelectFieldRenderer): + def render_probes(self, obj, field): + """ + This method is used by Appliance and Client views. + """ + if not obj.probes: + return "" - def render_readonly(self, **kwargs): - client = self.raw_value - if not client: - return '' - return tags.link_to(client, self.request.route_url('tempmon.clients.view', uuid=client.uuid)) + route_prefix = self.get_route_prefix() + actions = [self.make_grid_action_view()] + if self.request.has_perm('tempmon.probes.edit'): + actions.append(self.make_grid_action_edit()) -class ProbeFieldRenderer(SelectFieldRenderer): - - def render_readonly(self, **kwargs): - probe = self.raw_value - if not probe: - return '' - return tags.link_to(probe, self.request.route_url('tempmon.probes.view', uuid=probe.uuid)) + factory = self.get_grid_factory() + g = factory( + self.request, + key=f'{route_prefix}.probes', + data=[], + columns=[ + 'description', + 'critical_temp_min', + 'good_temp_min', + 'good_temp_max', + 'critical_temp_max', + 'status', + 'enabled', + ], + labels={ + 'critical_temp_min': "Crit. Min", + 'good_temp_min': "Good Min", + 'good_temp_max': "Good Max", + 'critical_temp_max': "Crit. Max", + }, + linked_columns=['description'], + actions=actions, + ) + return HTML.literal( + g.render_table_element(data_prop='probesData')) diff --git a/tailbone/views/tempmon/dashboard.py b/tailbone/views/tempmon/dashboard.py new file mode 100644 index 00000000..515eabc9 --- /dev/null +++ b/tailbone/views/tempmon/dashboard.py @@ -0,0 +1,158 @@ +# -*- 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/>. +# +################################################################################ +""" +Tempmon "Dashboard" View +""" + +import datetime + +from rattail.time import localtime, make_utc +from rattail_tempmon.db import model as tempmon + +from tailbone.views import View +from tailbone.db import TempmonSession + + +class TempmonDashboardView(View): + """ + Dashboard view for tempmon + """ + session_key = 'tempmon.dashboard.appliance_uuid' + + def dashboard(self): + + if self.request.method == 'POST': + appliance = None + uuid = self.request.POST.get('appliance_uuid') + if uuid: + appliance = TempmonSession.get(tempmon.Appliance, uuid) + if appliance: + self.request.session[self.session_key] = appliance.uuid + if not appliance: + self.request.session.flash("Appliance could not be found: {}".format(uuid), 'error') + raise self.redirect(self.request.current_route_url()) + + selected_uuid = self.request.params.get('appliance_uuid') + selected_appliance = None + if not selected_uuid: + selected_uuid = self.request.session.get(self.session_key) + if not selected_uuid: + # must declare the "first" appliance selected + selected_appliance = TempmonSession.query(tempmon.Appliance)\ + .order_by(tempmon.Appliance.name)\ + .first() + if selected_appliance: + selected_uuid = selected_appliance.uuid + self.request.session[self.session_key] = selected_uuid + + if not selected_appliance and 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() + + context['appliances_data'] = [{'uuid': a.uuid, + 'name': a.name} + for a in appliances] + + return context + + def readings(self): + + # track down the requested appliance + uuid = self.request.params.get('appliance_uuid') + if not uuid: + return {'error': "Must specify valid appliance_uuid"} + appliance = TempmonSession.get(tempmon.Appliance, uuid) + if not appliance: + return {'error': "Must specify valid appliance_uuid"} + + # remember which appliance was shown last + self.request.session[self.session_key] = appliance.uuid + + # fetch all "current" (recent) readings for all connected probes + probes = [] + cutoff = make_utc() - datetime.timedelta(seconds=7200) # 2 hours ago + for probe in appliance.probes: + probes.append({ + 'uuid': probe.uuid, + 'description': probe.description, + 'location': probe.location, + 'enabled': str(probe.enabled) if probe.enabled else None, + 'readings': self.get_probe_readings(probe, cutoff), + }) + return {'probes': probes} + + def get_probe_readings(self, probe, cutoff): + + # figure out which readings we need to graph + readings = TempmonSession.query(tempmon.Reading)\ + .filter(tempmon.Reading.probe == probe)\ + .filter(tempmon.Reading.taken >= cutoff)\ + .order_by(tempmon.Reading.taken)\ + .all() + + # convert readings to data for scatter plot + return [{ + 'x': localtime(self.rattail_config, reading.taken, from_utc=True).isoformat(), + 'y': float(reading.degrees_f), + } for reading in readings] + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + + # dashboard + config.add_tailbone_permission('tempmon.appliances', 'tempmon.appliances.dashboard', + "View the Tempmon Appliance \"Dashboard\" page") + config.add_route('tempmon.dashboard', '/tempmon/dashboard', + request_method=('GET', 'POST')) + config.add_view(cls, attr='dashboard', route_name='tempmon.dashboard', + renderer='/tempmon/dashboard.mako') + + # readings + config.add_route('tempmon.dashboard.readings', '/tempmon/dashboard/readings', + request_method='GET') + config.add_view(cls, attr='readings', route_name='tempmon.dashboard.readings', + renderer='json') + + +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 4add1ca2..573f9a2d 100644 --- a/tailbone/views/tempmon/probes.py +++ b/tailbone/views/tempmon/probes.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. # @@ -24,25 +24,17 @@ Views for tempmon probes """ -from __future__ import unicode_literals, absolute_import +import datetime from rattail_tempmon.db import model as tempmon -import formalchemy as fa +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags -from tailbone import forms -from tailbone.db import TempmonSession -from tailbone.views.tempmon import MasterView, ClientFieldRenderer - - -def unique_config_key(value, field): - probe = field.parent.model - query = TempmonSession.query(tempmon.Probe)\ - .filter(tempmon.Probe.config_key == value) - if probe.uuid: - query = query.filter(tempmon.Probe.uuid != probe.uuid) - if query.count(): - raise fa.ValidationError("Config key must be unique") +from tailbone import forms, grids +from tailbone.views.tempmon import MasterView +from tailbone.util import raw_datetime class TempmonProbeView(MasterView): @@ -55,9 +47,21 @@ class TempmonProbeView(MasterView): route_prefix = 'tempmon.probes' url_prefix = '/tempmon/probes' + has_rows = True + model_row_class = tempmon.Reading + rows_title = "Readings" + + labels = { + 'critical_max_timeout': "Critical High Timeout", + 'good_max_timeout': "High Timeout", + 'good_min_timeout': "Low Timeout", + 'critical_min_timeout': "Critical Low Timeout", + } + grid_columns = [ 'client', 'config_key', + 'appliance', 'appliance_type', 'description', 'device_path', @@ -65,17 +69,47 @@ class TempmonProbeView(MasterView): 'status', ] - def configure_grid(self, g): - super(TempmonProbeView, self).configure_grid(g) + form_fields = [ + 'client', + 'config_key', + 'appliance', + 'appliance_type', + 'description', + 'location', + 'device_path', + 'critical_temp_max', + 'critical_max_timeout', + 'good_temp_max', + 'good_max_timeout', + 'good_temp_min', + 'good_min_timeout', + 'critical_temp_min', + 'critical_min_timeout', + 'error_timeout', + 'therm_status_timeout', + 'status_alert_timeout', + 'notes', + 'enabled', + 'status', + ] - g.joiners['client'] = lambda q: q.join(tempmon.Client) - g.sorters['client'] = g.make_sorter(tempmon.Client.config_key) - g.default_sortkey = 'client' + row_grid_columns = [ + 'degrees_f', + 'taken', + ] + + def configure_grid(self, g): + super().configure_grid(g) + + # 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) g.set_enum('status', self.enum.TEMPMON_PROBE_STATUS) - g.set_type('enabled', 'boolean') + g.set_renderer('enabled', self.render_enabled_grid) g.set_label('config_key', "Key") @@ -83,31 +117,108 @@ class TempmonProbeView(MasterView): g.set_link('config_key') g.set_link('description') - def _preconfigure_fieldset(self, fs): - fs.config_key.set(validate=unique_config_key) - fs.client.set(label="TempMon Client", renderer=ClientFieldRenderer) - fs.appliance_type.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.TEMPMON_APPLIANCE_TYPE)) - fs.status.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.TEMPMON_PROBE_STATUS)) + def render_enabled_grid(self, probe, field): + if probe.enabled: + return "Yes" + return "No" - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.client, - fs.config_key, - fs.appliance_type, - fs.description, - fs.device_path, - fs.critical_temp_min, - fs.good_temp_min, - fs.good_temp_max, - fs.critical_temp_max, - fs.therm_status_timeout, - fs.status_alert_timeout, - fs.enabled, - fs.status, - ]) + def configure_form(self, f): + super().configure_form(f) + + # config_key + f.set_validator('config_key', self.unique_config_key) + + # client + f.set_renderer('client', self.render_client) + f.set_label('client', "Tempmon Client") if self.creating or self.editing: - del fs.status + f.replace('client', 'client_uuid') + clients = self.Session.query(tempmon.Client) + if self.creating: + clients = clients.filter(tempmon.Client.archived == False) + clients = clients.order_by(tempmon.Client.config_key) + client_values = [(client.uuid, "{} ({})".format(client.config_key, client.hostname)) + for client in clients] + f.set_widget('client_uuid', dfwidget.SelectWidget(values=client_values)) + f.set_label('client_uuid', "Tempmon Client") + + # appliance + f.set_renderer('appliance', self.render_appliance) + if self.creating or self.editing: + f.replace('appliance', 'appliance_uuid') + appliances = self.Session.query(tempmon.Appliance)\ + .order_by(tempmon.Appliance.name) + appliance_values = [(appliance.uuid, appliance.name) + for appliance in appliances] + appliance_values.insert(0, ('', "(none)")) + f.set_widget('appliance_uuid', dfwidget.SelectWidget(values=appliance_values)) + f.set_label('appliance_uuid', "Appliance") + + # appliance_type + f.set_enum('appliance_type', self.enum.TEMPMON_APPLIANCE_TYPE) + + # therm_status_timeout + f.set_helptext('therm_status_timeout', tempmon.Probe.therm_status_timeout.__doc__) + + # status_alert_timeout + f.set_helptext('status_alert_timeout', tempmon.Probe.status_alert_timeout.__doc__) + + # notes + f.set_type('notes', 'text') + + # status + f.set_enum('status', self.enum.TEMPMON_PROBE_STATUS) + if self.creating or self.editing: + f.remove_fields('status') + + # enabled + if self.creating or self.editing: + f.set_node('enabled', forms.types.DateTimeBoolean()) + else: + f.set_renderer('enabled', self.render_enabled_form) + f.set_helptext('enabled', tempmon.Probe.enabled.__doc__) + + def objectify(self, form, data=None): + + # this is a hack to prevent updates to the 'enabled' timestamp, when + # simple edits are being done to the probe. i.e. we do want to set the + # timestamp when it was previously null, but not otherwise. + if self.editing: + data = dict(data or form.validated) + if data['enabled'] and form.model_instance.enabled: + data['enabled'] = form.model_instance.enabled + + return super().objectify(form, data=data) + + def unique_config_key(self, node, value): + query = self.Session.query(tempmon.Probe)\ + .filter(tempmon.Probe.config_key == value) + if self.editing: + probe = self.get_instance() + query = query.filter(tempmon.Probe.uuid != probe.uuid) + if query.count(): + raise colander.Invalid(node, "Config key must be unique") + + def render_client(self, probe, field): + client = probe.client + if not client: + return "" + text = str(client) + url = self.request.route_url('tempmon.clients.view', uuid=client.uuid) + return tags.link_to(text, url) + + def render_appliance(self, probe, field): + appliance = probe.appliance + if not appliance: + return "" + text = str(appliance) + url = self.request.route_url('tempmon.appliances.view', uuid=appliance.uuid) + return tags.link_to(text, url) + + def render_enabled_form(self, probe, field): + if probe.enabled: + return raw_datetime(self.rattail_config, probe.enabled) + return "No" def delete_instance(self, probe): # bulk-delete all readings first @@ -122,6 +233,105 @@ class TempmonProbeView(MasterView): self.Session.delete(probe) self.Session.flush() + def get_row_data(self, probe): + query = self.Session.query(tempmon.Reading)\ + .filter(tempmon.Reading.probe == probe) + return query + + def get_parent(self, reading): + return reading.client + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + # # probe + # g.set_filter('probe', tempmon.Probe.description) + # g.set_sorter('probe', tempmon.Probe.description) + + g.set_sort_defaults('taken', 'desc') + + def graph(self): + probe = self.get_instance() + + key = 'tempmon.probe.{}.graph_time_range'.format(probe.uuid) + selected = self.request.params.get('time-range') + if not selected: + selected = self.request.session.get(key, 'last hour') + self.request.session[key] = selected + + context = { + 'probe': probe, + 'parent_title': str(probe), + 'parent_url': self.get_action_url('view', probe), + '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) + selected = self.request.params['time-range'] + assert selected + self.request.session[key] = selected + + # figure out what our window of time is + if selected == 'last hour': + cutoff = 60 * 60 # seconds x minutes + elif selected == 'last 6 hours': + cutoff = 60 * 60 * 6 # hour x 6 + elif selected == 'last day': + cutoff = 60 * 60 * 24 # hour x 24 + elif selected == 'last week': + cutoff = 60 * 60 * 24 * 7 # day x 7 + else: + raise NotImplementedError("Unknown time range: {}".format(selected)) + + # figure out which readings we need to graph + cutoff = app.make_utc() - datetime.timedelta(seconds=cutoff) + readings = self.Session.query(tempmon.Reading)\ + .filter(tempmon.Reading.probe == probe)\ + .filter(tempmon.Reading.taken >= cutoff)\ + .order_by(tempmon.Reading.taken)\ + .all() + + # convert readings to data for scatter plot + data = [{ + 'x': app.localtime(reading.taken, from_utc=True).isoformat(), + 'y': float(reading.degrees_f), + } for reading in readings] + return data + + @classmethod + def defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + model_key = cls.get_model_key() + permission_prefix = cls.get_permission_prefix() + model_title_plural = cls.get_model_title_plural() + + # graph + config.add_route('{}.graph'.format(route_prefix), '{}/{{{}}}/graph'.format(url_prefix, model_key), + request_method='GET') + config.add_view(cls, attr='graph', route_name='{}.graph'.format(route_prefix), + permission='{}.view'.format(permission_prefix)) + + # graph_readings + config.add_route('{}.graph_readings'.format(route_prefix), '{}/{{{}}}/graph-readings'.format(url_prefix, model_key), + request_method='GET') + config.add_view(cls, attr='graph_readings', route_name='{}.graph_readings'.format(route_prefix), + permission='{}.view'.format(permission_prefix), renderer='json') + + cls._defaults(config) + + +def defaults(config, **kwargs): + base = globals() + + TempmonProbeView = kwargs.get('TempmonProbeView', base['TempmonProbeView']) + TempmonProbeView.defaults(config) + def includeme(config): - TempmonProbeView.defaults(config) + defaults(config) diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py index 5181c41e..02e3fc51 100644 --- a/tailbone/views/tempmon/readings.py +++ b/tailbone/views/tempmon/readings.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. # @@ -24,15 +24,13 @@ Views for tempmon readings """ -from __future__ import unicode_literals, absolute_import - from sqlalchemy import orm from rattail_tempmon.db import model as tempmon -import formalchemy as fa +from webhelpers2.html import tags -from tailbone.views.tempmon import MasterView, ClientFieldRenderer, ProbeFieldRenderer +from tailbone.views.tempmon import MasterView class TempmonReadingView(MasterView): @@ -56,27 +54,36 @@ class TempmonReadingView(MasterView): 'degrees_f', ] + form_fields = [ + 'client', + 'probe', + 'taken', + 'degrees_f', + ] + def query(self, session): return session.query(tempmon.Reading)\ .join(tempmon.Client)\ .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) - - g.default_sortkey = 'taken' - g.default_sortdir = 'desc' + # 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') g.set_renderer('client_key', self.render_client_key) @@ -91,19 +98,40 @@ class TempmonReadingView(MasterView): def render_client_host(self, reading, column): return reading.client.hostname - def _preconfigure_fieldset(self, fs): - fs.client.set(label="TempMon Client", renderer=ClientFieldRenderer) - fs.probe.set(label="TempMon Probe", renderer=ProbeFieldRenderer) + def configure_form(self, f): + super().configure_form(f) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.client, - fs.probe, - fs.taken, - fs.degrees_f, - ]) + # client + f.set_renderer('client', self.render_client) + f.set_label('client', "Tempmon Client") + + # probe + f.set_renderer('probe', self.render_probe) + f.set_label('probe', "Tempmon Probe") + + def render_client(self, reading, field): + client = reading.client + if not client: + return "" + text = str(client) + url = self.request.route_url('tempmon.clients.view', uuid=client.uuid) + return tags.link_to(text, url) + + def render_probe(self, reading, field): + probe = reading.probe + if not probe: + return "" + text = str(probe) + url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) + return tags.link_to(text, url) + + +def defaults(config, **kwargs): + base = globals() + + TempmonReadingView = kwargs.get('TempmonReadingView', base['TempmonReadingView']) + TempmonReadingView.defaults(config) def includeme(config): - TempmonReadingView.defaults(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.py b/tailbone/views/trainwreck.py deleted file mode 100644 index 477adc53..00000000 --- a/tailbone/views/trainwreck.py +++ /dev/null @@ -1,177 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU 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/>. -# -################################################################################ -""" -Trainwreck views -""" - -from __future__ import unicode_literals, absolute_import - -import six - -from rattail.time import localtime - -from tailbone import forms -from tailbone.db import TrainwreckSession -from tailbone.views import MasterView2 as MasterView - - -class TransactionView(MasterView): - """ - Master view for Trainwreck transactions - """ - Session = TrainwreckSession - # model_class = trainwreck.Transaction - model_title = "Trainwreck Transaction" - model_title_plural = "Trainwreck Transactions" - route_prefix = 'trainwreck.transactions' - url_prefix = '/trainwreck/transactions' - creatable = False - editable = False - deletable = False - - grid_columns = [ - 'start_time', - 'system', - 'terminal_id', - 'receipt_number', - 'cashier_name', - 'customer_id', - 'customer_name', - 'total', - ] - - has_rows = True - # model_row_class = trainwreck.TransactionItem - rows_default_pagesize = 100 - - row_grid_columns = [ - 'sequence', - 'item_type', - 'item_scancode', - 'department_number', - 'description', - 'unit_quantity', - 'subtotal', - 'tax', - 'total', - 'void', - ] - - def configure_grid(self, g): - super(TransactionView, self).configure_grid(g) - g.filters['receipt_number'].default_active = True - g.filters['receipt_number'].default_verb = 'equal' - g.filters['start_time'].default_active = True - g.filters['start_time'].default_verb = 'equal' - g.filters['start_time'].default_value = six.text_type(localtime(self.rattail_config).date()) - g.default_sortkey = 'start_time' - g.default_sortdir = 'desc' - - g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) - g.set_type('total', 'currency') - g.set_label('terminal_id', "Terminal") - g.set_label('receipt_number', "Receipt No.") - g.set_label('customer_id', "Customer ID") - - g.set_link('start_time') - g.set_link('receipt_number') - g.set_link('customer_id') - g.set_link('customer_name') - g.set_link('total') - - def _preconfigure_fieldset(self, fs): - fs.system.set(renderer=forms.renderers.EnumFieldRenderer(self.enum.TRAINWRECK_SYSTEM)) - fs.terminal_id.set(label="Terminal") - fs.cashier_id.set(label="Cashier ID") - fs.customer_id.set(label="Customer ID") - fs.shopper_id.set(label="Shopper ID") - fs.subtotal.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.discounted_subtotal.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.tax.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.total.set(renderer=forms.renderers.CurrencyFieldRenderer) - - def configure_fieldset(self, fs): - fs.configure(include=[ - fs.system, - fs.terminal_id, - fs.receipt_number, - fs.start_time, - fs.end_time, - fs.upload_time, - fs.cashier_id, - fs.cashier_name, - fs.customer_id, - fs.customer_name, - fs.shopper_id, - fs.shopper_name, - fs.subtotal, - fs.discounted_subtotal, - fs.tax, - fs.total, - fs.void, - ]) - - def get_row_data(self, transaction): - return self.Session.query(self.model_row_class)\ - .filter(self.model_row_class.transaction == transaction)\ - .order_by(self.model_row_class.sequence) - - def get_parent(self, item): - return item.transaction - - def configure_row_grid(self, g): - super(TransactionView, self).configure_row_grid(g) - g.default_sortkey = 'sequence' - - g.set_type('unit_quantity', 'quantity') - g.set_type('subtotal', 'currency') - g.set_type('discounted_subtotal', 'currency') - g.set_type('tax', 'currency') - g.set_type('total', 'currency') - - g.set_label('item_id', "Item ID") - g.set_label('department_number', "Dept. No.") - - def _preconfigure_row_fieldset(self, fs): - fs.item_id.set(label="Item ID") - fs.department_number.set(label="Dept. No.") - fs.unit_quantity.set(renderer=forms.renderers.QuantityFieldRenderer) - fs.subtotal.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.discounted_subtotal.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.tax.set(renderer=forms.renderers.CurrencyFieldRenderer) - fs.total.set(renderer=forms.renderers.CurrencyFieldRenderer) - - def configure_row_fieldset(self, fs): - fs.configure(include=[ - fs.sequence, - fs.item_type, - fs.item_scancode, - fs.item_id, - fs.department_number, - fs.description, - fs.unit_quantity, - fs.subtotal, - fs.tax, - fs.total, - fs.void, - ]) diff --git a/fabfile.py b/tailbone/views/trainwreck/__init__.py similarity index 75% rename from fabfile.py rename to tailbone/views/trainwreck/__init__.py index cc2c7b71..b5eea351 100644 --- a/fabfile.py +++ b/tailbone/views/trainwreck/__init__.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,19 +21,13 @@ # ################################################################################ """ -Fabric script for Tailbone +Trainwreck Views """ from __future__ import unicode_literals, absolute_import -import shutil -from fabric.api import task, local +from .base import TransactionView -@task -def release(): - """ - Release a new version of 'Tailbone'. - """ - shutil.rmtree('Tailbone.egg-info') - local('python setup.py sdist --formats=gztar upload') +def includeme(config): + config.include('tailbone.views.trainwreck.defaults') diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py new file mode 100644 index 00000000..d5f077aa --- /dev/null +++ b/tailbone/views/trainwreck/base.py @@ -0,0 +1,515 @@ +# -*- 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/>. +# +################################################################################ +""" +Trainwreck views +""" + +from webhelpers2.html import HTML, tags + +from tailbone.db import Session, TrainwreckSession, ExtraTrainwreckSessions +from tailbone.views import MasterView + + +class TransactionView(MasterView): + """ + Master view for Trainwreck transactions + """ + # model_class = trainwreck.Transaction + model_title = "Trainwreck Transaction" + model_title_plural = "Trainwreck Transactions" + route_prefix = 'trainwreck.transactions' + url_prefix = '/trainwreck/transactions' + 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", + } + + grid_columns = [ + 'start_time', + 'end_time', + 'system', + 'store_id', + 'terminal_id', + 'receipt_number', + 'cashier_name', + 'customer_id', + 'customer_name', + 'total', + ] + + form_fields = [ + 'system', + 'system_id', + 'store_id', + 'terminal_id', + 'receipt_number', + 'effective_date', + 'start_time', + 'end_time', + 'upload_time', + 'cashier_id', + 'cashier_name', + 'customer_id', + 'customer_name', + 'shopper_id', + 'shopper_name', + 'shopper_level_number', + 'custorder_xref_markers', + 'subtotal', + 'discounted_subtotal', + 'tax', + 'cashback', + 'total', + 'patronage', + 'equity_current', + 'self_updated', + 'void', + ] + + has_rows = True + # model_row_class = trainwreck.TransactionItem + rows_default_pagesize = 100 + + row_labels = { + 'item_id': "Item ID", + 'department_number': "Dept. No.", + 'subdepartment_number': "Subdept. No.", + } + + row_grid_columns = [ + 'sequence', + 'item_type', + 'item_scancode', + 'department_number', + 'subdepartment_number', + 'description', + 'unit_quantity', + 'subtotal', + 'tax', + 'total', + 'void', + ] + + row_form_fields = [ + 'transaction', + 'sequence', + 'item_type', + 'item_scancode', + 'item_id', + 'department_number', + 'department_name', + '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): + 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().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' + # 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") + + g.set_link('start_time') + g.set_link('end_time') + g.set_link('upload_time') + g.set_link('receipt_number') + g.set_link('customer_id') + g.set_link('customer_name') + g.set_link('total') + + def grid_extra_class(self, transaction, i): + if transaction.void: + return 'warning' + + def configure_form(self, f): + super().configure_form(f) + + # system + f.set_enum('system', self.enum.TRAINWRECK_SYSTEM) + + # currency fields + f.set_type('subtotal', 'currency') + f.set_type('discounted_subtotal', 'currency') + 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") + f.set_label('terminal_id', "Terminal") + f.set_label('cashier_id', "Cashier ID") + 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) + + def get_parent(self, item): + return item.transaction + + def configure_row_grid(self, g): + super().configure_row_grid(g) + g.set_sort_defaults('sequence') + + g.set_type('unit_quantity', 'quantity') + g.set_type('subtotal', 'currency') + g.set_type('discounted_subtotal', 'currency') + 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().configure_row_form(f) + + # transaction + f.set_renderer('transaction', self.render_transaction) + + # quantity fields + f.set_type('unit_quantity', 'quantity') + + # currency fields + f.set_type('unit_price', 'currency') + f.set_type('subtotal', 'currency') + 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 new file mode 100644 index 00000000..85c61fae --- /dev/null +++ b/tailbone/views/trainwreck/defaults.py @@ -0,0 +1,50 @@ +# -*- 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/>. +# +################################################################################ +""" +Trainwreck "default" views (i.e. assuming "default" schema) +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.trainwreck.db.model import defaults as trainwreck + +from tailbone.views.trainwreck import base + + +class TransactionView(base.TransactionView): + """ + Master view for Trainwreck transactions + """ + model_class = trainwreck.Transaction + model_row_class = trainwreck.TransactionItem + + +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 new file mode 100644 index 00000000..0b7b060f --- /dev/null +++ b/tailbone/views/uoms.py @@ -0,0 +1,137 @@ +# -*- 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/>. +# +################################################################################ +""" +UOM Views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db import model + +from tailbone.views import MasterView + + +class UnitOfMeasureView(MasterView): + """ + Master view for the UOM mappings. + """ + model_class = model.UnitOfMeasure + route_prefix = 'uoms' + url_prefix = '/units-of-measure' + bulk_deletable = True + has_versions = True + + labels = { + 'sil_code': "SIL Code", + } + + grid_columns = [ + 'abbreviation', + 'sil_code', + 'description', + 'notes', + ] + + form_fields = [ + 'abbreviation', + 'sil_code', + 'description', + 'notes', + ] + + def configure_grid(self, g): + super(UnitOfMeasureView, self).configure_grid(g) + + g.set_renderer('description', self.render_description) + + g.set_sort_defaults('abbreviation') + + g.set_link('abbreviation') + g.set_link('description') + + def configure_form(self, f): + super(UnitOfMeasureView, self).configure_form(f) + + f.set_renderer('description', self.render_description) + f.set_type('notes', 'text') + + if not self.creating: + f.set_readonly('abbreviation') + + if self.creating or self.editing: + f.remove('description') + f.set_enum('sil_code', self.enum.UNIT_OF_MEASURE) + + def redirect_after_create(self, uom, **kwargs): + return self.redirect(self.get_index_url()) + + def redirect_after_edit(self, uom, **kwargs): + return self.redirect(self.get_index_url()) + + def render_description(self, uom, field): + code = uom.sil_code + if code: + if code in self.enum.UNIT_OF_MEASURE: + return self.enum.UNIT_OF_MEASURE[code] + return "(unknown code)" + + def collect_wild_uoms(self): + app = self.get_rattail_app() + handler = app.get_products_handler() + uoms = handler.collect_wild_uoms() + self.request.session.flash("All abbreviations from the wild have been added. Now please map each to a SIL code.") + return self.redirect(self.get_index_url()) + + @classmethod + def defaults(cls, config): + cls._uom_defaults(config) + cls._defaults(config) + + @classmethod + def _uom_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 name + config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) + + # collect wild uoms + config.add_tailbone_permission(permission_prefix, '{}.collect_wild_uoms'.format(permission_prefix), + "Collect UoM abbreviations from the wild") + config.add_route('{}.collect_wild_uoms'.format(route_prefix), '{}/collect-wild-uoms'.format(url_prefix), + request_method='POST') + config.add_view(cls, attr='collect_wild_uoms', route_name='{}.collect_wild_uoms'.format(route_prefix), + permission='{}.collect_wild_uoms'.format(permission_prefix)) + + +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 6005c14f..ffa88032 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.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,27 +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 pip.download import PipSession -from pip.req import parse_requirements -from sqlalchemy import orm +import sqlalchemy as sa -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 MasterView3 as MasterView -from tailbone.progress import SessionProgress, get_progress_session +from tailbone.views import MasterView +from tailbone.progress import get_progress_session #, SessionProgress +from tailbone.config import should_expose_websockets log = logging.getLogger(__name__) @@ -54,13 +51,14 @@ class UpgradeView(MasterView): """ Master view for all user events """ - model_class = model.Upgrade + model_class = Upgrade downloadable = True cloneable = True + configurable = True executable = True execute_progress_template = '/upgrade.mako' execute_progress_initial_msg = "Upgrading" - supports_prev_next = True + execute_can_cancel = False labels = { 'executed_by': "Executed by", @@ -70,6 +68,7 @@ class UpgradeView(MasterView): } grid_columns = [ + 'system', 'created', 'description', # 'not_until', @@ -80,6 +79,7 @@ class UpgradeView(MasterView): ] form_fields = [ + 'system', 'description', # 'not_until', # 'requirements', @@ -87,6 +87,7 @@ class UpgradeView(MasterView): 'created', 'created_by', 'enabled', + 'executing', 'executed', 'executed_by', 'status_code', @@ -97,30 +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.default_sortkey = 'created' - g.default_sortdir = 'desc' + g.set_sort_defaults('created', 'desc') + + g.set_link('system') g.set_link('created') g.set_link('description') # g.set_link('not_until') @@ -133,67 +146,140 @@ 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 + upgrades = self.Session.query(model.Upgrade)\ .filter(model.Upgrade.uuid != upgrade.uuid) - kwargs['prev_instance'] = upgrades.filter(model.Upgrade.created <= upgrade.created)\ - .order_by(model.Upgrade.created.desc())\ - .first() - kwargs['next_instance'] = upgrades.filter(model.Upgrade.created >= upgrade.created)\ - .order_by(model.Upgrade.created)\ - .first() + older = upgrades.filter(model.Upgrade.created <= upgrade.created)\ + .order_by(model.Upgrade.created.desc())\ + .first() + newer = upgrades.filter(model.Upgrade.created >= upgrade.created)\ + .order_by(model.Upgrade.created)\ + .first() + + if older: + kwargs['prev_url'] = self.get_action_url('view', older) + if newer: + kwargs['next_url'] = self.get_action_url('view', newer) + return kwargs def configure_form(self, f): - super(UpgradeView, self).configure_form(f) - f.set_enum('status_code', self.enum.UPGRADE_STATUS) + 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_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') f.remove_field('stdout_file') f.remove_field('stderr_file') - if self.creating: - f.remove_field('status_code') - else: - f.set_readonly('status_code') 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 @@ -218,47 +304,92 @@ class UpgradeView(MasterView): try: before = self.parse_requirements(upgrade, 'before') after = self.parse_requirements(upgrade, 'after') + + kwargs = {} + 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, render_value=self.render_diff_value, - ) - showing = HTML.tag('div', - "showing: " - + tags.link_to("all", '#', class_='all') - + " / " - + tags.link_to("diffs only", '#', class_='diffs'), - class_='showing') - return showing + diff.render_html() + **kwargs) + + kwargs = {} + kwargs['@click.prevent'] = "showingPackages = 'all'" + kwargs[':style'] = "{'font-weight': showingPackages == 'all' ? 'bold' : null}" + all_link = tags.link_to("all", '#', **kwargs) + + kwargs = {} + kwargs['@click.prevent'] = "showingPackages = 'diffs'" + kwargs[':style'] = "{'font-weight': showingPackages == 'diffs' ? 'bold' : null}" + diffs_link = tags.link_to("diffs only", '#', **kwargs) + + kwargs = {} + showing = HTML.tag('div', c=["showing: " + + all_link + + " / " + + diffs_link], + **kwargs) + + return HTML.tag('div', c=[showing + diff.render_html()]) + except: log.debug("failed to render package diff for upgrade: {}".format(upgrade), exc_info=True) - return "(not available for this upgrade)" + return HTML.tag('div', c="(not available for this upgrade)") + + def get_extra_diff_row_attrs(self, field, attrs): + extra = {} + if attrs.get('class') != 'diff': + extra['v-show'] = "showingPackages == 'all'" + return extra def changelog_link(self, project, url): return tags.link_to(project, url, target='_blank') commit_hash_pattern = re.compile(r'^.{40}$') - def get_changelog_url(self, project, old_version, new_version): - projects = { + def get_changelog_projects(self): + 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', } - if project not in projects: + + 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 + if project_name not in projects: + # cannot generate a changelog URL for unknown project + return + + project = projects[project_name] + if self.commit_hash_pattern.match(new_version): - if new_version == old_version: - return 'https://rattailproject.org/trac/log/{}/?rev={}&limit=100'.format( - projects[project], new_version) - else: - return 'https://rattailproject.org/trac/log/{}/?rev={}&stop_rev={}&limit=100'.format( - projects[project], new_version, old_version) + return project['commit_url'].format(new_version=new_version, old_version=old_version) + elif re.match(r'^\d+\.\d+\.\d+$', new_version): - return 'https://rattailproject.org/trac/browser/{}/CHANGES.rst?rev=v{}'.format( - projects[project], new_version) - else: - return 'https://rattailproject.org/trac/browser/{}/CHANGES.rst'.format( - projects[project]) + return project['release_url'].format(new_version=new_version, old_version=old_version) def render_diff_field(self, field, diff): old_version = diff.old_value(field) @@ -278,24 +409,26 @@ class UpgradeView(MasterView): def parse_requirements(self, upgrade, type_): packages = {} path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='requirements.{}.txt'.format(type_)) - session = PipSession() - for req in parse_requirements(path, session=session): - version = self.version_from_requirement(req) - packages[req.name] = version + with open(path, 'rt') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + req = self.parse_requirement(line) + if req: + packages[req.name] = req.version + else: + log.warning("could not parse req from line: %s", line) return packages - def version_from_requirement(self, req): - if req.specifier: - match = re.match(r'^==(.*)$', six.text_type(req.specifier)) - if match: - return match.group(1) - return six.text_type(req.specifier) - elif req.link: - match = re.match(r'^.*@(.*)#egg=.*$', six.text_type(req.link)) - if match: - return match.group(1) - return six.text_type(req.link) - return "" + def parse_requirement(self, line): + app = self.get_rattail_app() + match = re.match(r'^.*@(.*)#egg=(.*)$', line) + if match: + return app.make_object(name=match.group(2), version=match.group(1)) + + match = re.match(r'^(.*)==(.*)$', line) + if match: + 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) @@ -313,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() @@ -335,33 +486,115 @@ class UpgradeView(MasterView): offset = session.get('stdout.offset', 0) if os.path.exists(path): size = os.path.getsize(path) - offset - with open(path, 'rb') as f: - f.seek(offset) - chunk = f.read(size) - data['stdout'] = chunk.decode('utf8').replace('\n', '<br />') - session['stdout.offset'] = offset + size - session.save() + if size > 0: + with open(path, 'rb') as f: + f.seek(offset) + chunk = f.read(size) + data['stdout'] = chunk.decode('utf8').replace('\n', '<br />') + session['stdout.offset'] = offset + size + session.save() 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 7fb598b8..dfed0a11 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.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,149 +24,99 @@ User Views """ -from __future__ import unicode_literals, absolute_import - -import copy - +import sqlalchemy as sa from sqlalchemy import orm -from rattail.db import model -from rattail.db.auth import guest_role, authenticated_role, set_user_password, has_permission +from rattail.db.model import User, UserEvent -import wtforms -import formalchemy -from formalchemy.fields import SelectFieldRenderer +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 MasterView2 as MasterView -from tailbone.views.principal import PrincipalMasterView +from tailbone.views import MasterView, View +from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer +from tailbone.util import raw_datetime -def unique_username(value, field): - user = field.parent.model - query = Session.query(model.User).filter(model.User.username == value) - if user.uuid: - query = query.filter(model.User.uuid != user.uuid) - if query.count(): - raise formalchemy.ValidationError("Username must be unique.") - - -def passwords_match(value, field): - if field.parent.confirm_password.value != value: - raise formalchemy.ValidationError("Passwords do not match") - return value - - -class PasswordFieldRenderer(formalchemy.PasswordFieldRenderer): - - def render(self, **kwargs): - return tags.password(self.name, value='', maxlength=self.length, **kwargs) - - -class PasswordField(formalchemy.Field): - - def __init__(self, *args, **kwargs): - kwargs.setdefault('value', lambda x: x.password) - kwargs.setdefault('renderer', PasswordFieldRenderer) - kwargs.setdefault('validate', passwords_match) - super(PasswordField, self).__init__(*args, **kwargs) - - def sync(self): - if not self.is_readonly(): - password = self.renderer.deserialize() - if password: - set_user_password(self.model, password) - - -def RolesFieldRenderer(request): - - class RolesFieldRenderer(SelectFieldRenderer): - - def render_readonly(self, **kwargs): - roles = Session.query(model.Role) - html = '' - for uuid in self.value: - role = roles.get(uuid) - link = tags.link_to( - role.name, request.route_url('roles.view', uuid=role.uuid)) - html += HTML.tag('li', c=link) - html = HTML.tag('ul', c=html) - return html - - return RolesFieldRenderer - - -class RolesField(formalchemy.Field): - - def __init__(self, name, **kwargs): - kwargs.setdefault('value', self.get_value) - kwargs.setdefault('options', self.get_options()) - kwargs.setdefault('multiple', True) - super(RolesField, self).__init__(name, **kwargs) - - def get_value(self, user): - return [x.uuid for x in user.roles] - - def get_options(self): - return Session.query(model.Role.name, model.Role.uuid)\ - .filter(model.Role.uuid != guest_role(Session()).uuid)\ - .filter(model.Role.uuid != authenticated_role(Session()).uuid)\ - .order_by(model.Role.name)\ - .all() - - def sync(self): - if not self.is_readonly(): - user = self.model - roles = Session.query(model.Role) - data = self.renderer.deserialize() - user.roles = [roles.get(x) for x in data] - - -class UsersView(PrincipalMasterView): +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 - merge_additive_fields = [ - 'sent_message_count', - 'received_message_count', - ] - merge_fields = merge_additive_fields + [ - 'uuid', - 'username', - 'person_uuid', - 'person_name', - 'role_count', - 'active', - ] + + labels = { + 'api_tokens': "API Tokens", + } grid_columns = [ 'username', 'person', 'active', + 'local_only', ] + form_fields = [ + 'username', + 'person', + 'first_name_', + 'last_name_', + 'display_name_', + 'active', + 'active_sticky', + 'set_password', + 'prevent_password_change', + 'api_tokens', + 'roles', + 'permissions', + ] + + has_rows = True + model_row_class = UserEvent + rows_title = "User Events" + rows_viewable = False + row_grid_columns = [ 'type_code', 'occurred', ] + def __init__(self, request): + super().__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): - return session.query(model.User)\ - .outerjoin(model.Person)\ - .options(orm.joinedload(model.User.person)) + query = super().query(session) + model = self.model + + # bring in the related Person(s) + query = query.outerjoin(model.Person)\ + .options(orm.joinedload(model.User.person)) + + return query def configure_grid(self, g): - super(UsersView, self).configure_grid(g) + super().configure_grid(g) + model = self.model - del g.filters['password'] del g.filters['salt'] g.filters['username'].default_active = True g.filters['username'].default_verb = 'contains' @@ -174,14 +124,16 @@ class UsersView(PrincipalMasterView): g.filters['active'].default_verb = 'is_true' g.filters['person'] = g.make_filter('person', model.Person.display_name, default_active=True, default_verb='contains') - g.filters['password'] = g.make_filter('password', model.User.password, - verbs=['is_null', 'is_not_null']) + + # password + g.set_filter('password', model.User.password, + verbs=['is_null', 'is_not_null']) g.set_sorter('person', model.Person.display_name) g.set_sorter('first_name', model.Person.first_name) g.set_sorter('last_name', model.Person.last_name) g.set_sorter('display_name', model.Person.display_name) - g.default_sortkey = 'username' + g.set_sort_defaults('username') g.set_label('person', "Person's Name") @@ -191,76 +143,391 @@ class UsersView(PrincipalMasterView): g.set_link('last_name') g.set_link('display_name') - def _preconfigure_fieldset(self, fs): - fs.username.set(renderer=forms.renderers.StrippedTextFieldRenderer, validate=unique_username) - fs.person.set(renderer=forms.renderers.PersonFieldRenderer, options=[]) - fs.append(PasswordField('password', label="Set Password")) - fs.append(formalchemy.Field('confirm_password', renderer=PasswordFieldRenderer)) - fs.append(RolesField('roles', renderer=RolesFieldRenderer(self.request), size=10)) - fs.append(forms.AssociationProxyField('first_name')) - fs.append(forms.AssociationProxyField('last_name')) - fs.append(forms.AssociationProxyField('display_name', label="Full Name")) - - # hm this should work according to MDN but doesn't seem to... - # https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion - fs.username.attrs(autocomplete='new-password') - fs.password.attrs(autocomplete='new-password') - fs.confirm_password.attrs(autocomplete='new-password') - - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.username, - fs.person, - fs.first_name, - fs.last_name, - fs.display_name, - fs.active, - fs.active_sticky, - fs.password, - fs.confirm_password, - fs.roles, - ]) - if self.viewing: - permissions = self.request.registry.settings.get('tailbone_permissions', {}) - renderer = forms.renderers.PermissionsFieldRenderer(permissions, - include_guest=True, - include_authenticated=True) - fs.append(formalchemy.Field('permissions', renderer=renderer)) - if self.viewing or self.deleting: - del fs.password - del fs.confirm_password + def grid_extra_class(self, user, i): + if not user.active: + return 'warning' def editable_instance(self, user): - if self.rattail_config.demo(): - return user.username != 'chuck' - return True + """ + If the given user is "protected" then we only allow edit if current + user is "root". But if the given user is not protected, this simply + returns ``True``. + """ + if self.request.is_root: + return True + return not self.user_is_protected(user) def deletable_instance(self, user): - if self.rattail_config.demo(): - return user.username != 'chuck' - return True + """ + If the given user is "protected" then we only allow delete if current + user is "root". But if the given user is not protected, this simply + returns ``True``. + """ + if self.request.is_root: + return True + 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: + user = self.get_instance() + query = query.filter(model.User.uuid != user.uuid) + 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().configure_form(f) + model = self.model + user = f.model_instance + + # username + f.set_validator('username', self.unique_username) + + # person + f.set_renderer('person', self.render_person) + if self.creating or self.editing: + if 'person' in f.fields: + f.replace('person', 'person_uuid') + f.set_node('person_uuid', colander.String(), missing=colander.null) + person_display = "" + if self.request.method == 'POST': + if self.request.POST.get('person_uuid'): + person = self.Session.get(model.Person, self.request.POST['person_uuid']) + if person: + person_display = str(person) + elif self.editing: + 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) + if self.editing: + # must explicitly set default, for "custom" field names + f.set_default('first_name_', user.first_name or "") + f.set_default('last_name_', user.last_name or "") + f.set_default('display_name_', user.display_name or "") + elif not self.creating: + # must provide custom renderer as well + f.set_renderer('first_name_', self.render_person_name) + f.set_renderer('last_name_', self.render_person_name) + f.set_renderer('display_name_', self.render_person_name) + + # set_password + 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: + if not self.has_perm('edit_roles'): + f.remove_field('roles') + else: + roles = self.get_possible_roles().all() + role_values = [(s.uuid, str(s)) for s in roles] + f.set_node('roles', colander.Set()) + size = len(roles) + if size < 3: + size = 3 + elif size > 20: + size = 20 + f.set_widget('roles', dfwidget.SelectWidget(multiple=True, + size=size, + values=role_values)) + if self.editing: + f.set_default('roles', [r.uuid for r in user.roles]) + elif not self.has_perm('view_roles'): + f.remove_field('roles') + + f.set_label('display_name', "Full Name") + + # # hm this should work according to MDN but doesn't seem to... + # # https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion + # fs.username.attrs(autocomplete='new-password') + # fs.password.attrs(autocomplete='new-password') + # fs.confirm_password.attrs(autocomplete='new-password') + + if self.viewing: + 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 = [ + auth.get_role_anonymous(self.Session()).uuid, + auth.get_role_authenticated(self.Session()).uuid, + ] + + # only allow "root" user to change true admin role membership + if not self.request.is_root: + excluded.append(auth.get_role_administrator(self.Session()).uuid) + + # 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().objectify(form, data) + + # create/update person as needed + names = {} + if 'first_name_' in form and data['first_name_']: + names['first'] = data['first_name_'] + if 'last_name_' in form and data['last_name_']: + names['last'] = data['last_name_'] + if 'display_name_' in form and data['display_name_']: + names['full'] = data['display_name_'] + # we will not have a person reference yet, when creating new user. if + # that is the case, go ahead and load it, if specified. + if self.creating and user.person_uuid: + self.Session.add(user) + self.Session.flush() + # note, do *not* create new person unless name(s) provided + if not user.person and any([n for n in names.values()]): + user.person = model.Person() + if user.person: + app = self.get_rattail_app() + handler = app.get_people_handler() + handler.update_names(user.person, **names) + + # force "local only" flag unless global access granted + if self.secure_global_objects: + if not self.has_perm('view_global'): + user.person.local_only = True + + # maybe set user 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) + + return user + + def update_roles(self, user, data): + if not self.has_perm('edit_roles'): + return + 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 = 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 + for uuid in new_roles: + if uuid not in old_roles: + if self.request.is_root or uuid != admin.uuid: + user._roles.append(model.UserRole(role_uuid=uuid)) + + # also record a change to the role, for datasync. + # this is done "just in case" the role is to be + # synced to all nodes + if self.Session().rattail_record_changes: + self.Session.add(model.Change(class_name='Role', + instance_uuid=uuid, + deleted=False)) + + # remove any roles which were *not* specified, although must take care + # not to remove admin role, unless acting as root + for uuid in old_roles: + if uuid not in new_roles: + if self.request.is_root or uuid != admin.uuid: + role = self.Session.get(model.Role, uuid) + user.roles.remove(role) + + # also record a change to the role, for datasync. + # this is done "just in case" the role is to be + # synced to all nodes + if self.Session().rattail_record_changes: + self.Session.add(model.Change(class_name='Role', + instance_uuid=uuid, + deleted=False)) + + def render_person(self, user, field): + person = user.person + if not person: + return "" + text = str(person) + url = self.request.route_url('people.view', uuid=person.uuid) + return tags.link_to(person, url) + + def render_person_name(self, user, field): + if not field.endswith('_'): + return "" + name = getattr(user, field[:-1], None) + if not name: + return "" + return str(name) + + def render_roles(self, user, field): + roles = sorted(user.roles, key=lambda r: r.name) + items = [] + for role in roles: + text = role.name + url = self.request.route_url('roles.view', uuid=role.uuid) + items.append(HTML.tag('li', c=[tags.link_to(text, url)])) + 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(UsersView, self).configure_row_grid(g) + super().configure_row_grid(g) g.width = 'half' g.filterable = False - g.default_sortkey = 'occurred' - g.default_sortdir = 'desc' + 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)\ @@ -270,41 +537,214 @@ class UsersView(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(UsersView, 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): + cls._user_defaults(config) + cls._principal_defaults(config) + cls._defaults(config) + + @classmethod + def _user_defaults(cls, config): + """ + 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 + config.add_tailbone_permission(permission_prefix, '{}.view_roles'.format(permission_prefix), + "View the Roles to which a {} belongs".format(model_title)) + 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') -class UserEventsView(MasterView): +# TODO: deprecate / remove this +UsersView = UserView + + +class UserEventView(MasterView): """ Master view for all user events """ - model_class = model.UserEvent + model_class = UserEvent url_prefix = '/user-events' viewable = False creatable = False @@ -319,11 +759,13 @@ class UserEventsView(MasterView): ] def get_data(self, session=None): - query = super(UserEventsView, 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(UserEventsView, 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) @@ -333,8 +775,7 @@ class UserEventsView(MasterView): g.set_type('occurred', 'datetime') g.set_renderer('user', self.render_user) g.set_renderer('person', self.render_person) - g.default_sortkey = 'occurred' - g.default_sortdir = 'desc' + g.set_sort_defaults('occurred', 'desc') g.set_label('user', "Username") g.set_label('type_code', "Event Type") @@ -345,7 +786,23 @@ class UserEventsView(MasterView): if event.user.person: return event.user.person.display_name +# TODO: deprecate / remove this +UserEventsView = UserEventView + + +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): - UsersView.defaults(config) - UserEventsView.defaults(config) + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.users') + else: + defaults(config) diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index 31cef989..210df39e 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,14 @@ Views pertaining to vendors """ -from __future__ import unicode_literals, absolute_import +from .core import VendorView -from .core import VendorsView, VendorsAutocomplete + +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.catalogs') - config.include('tailbone.views.vendors.invoices') + config.include('tailbone.views.vendors.samplefiles') diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index b1a8999a..2471ad47 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.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,142 +21,16 @@ # ################################################################################ """ -Views for maintaining vendor catalogs +(Deprecated!) Views for maintaining vendor catalogs + +Please use `tailbone.views.batch.vendorcatalog` instead. """ -from __future__ import unicode_literals, absolute_import - -import logging - -from rattail.db import model, api -from rattail.vendors.catalogs import iter_catalog_parsers - -import formalchemy - -from tailbone import forms -from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView2 as FileBatchMasterView - - -log = logging.getLogger(__name__) - - -class VendorCatalogsView(FileBatchMasterView): - """ - Master view for vendor catalog batches. - """ - model_class = model.VendorCatalog - model_row_class = model.VendorCatalogRow - default_handler_spec = 'rattail.batch.vendorcatalog:VendorCatalogHandler' - url_prefix = '/vendors/catalogs' - editable = False - rows_bulk_deletable = True - - grid_columns = [ - 'created', - 'created_by', - 'vendor', - 'effective', - 'filename', - 'executed', - ] - - row_grid_columns = [ - 'sequence', - 'upc', - 'brand_name', - 'description', - 'size', - 'vendor_code', - 'old_unit_cost', - 'unit_cost', - 'unit_cost_diff', - 'status_code', - ] - - def get_parsers(self): - if not hasattr(self, 'parsers'): - self.parsers = sorted(iter_catalog_parsers(), key=lambda p: p.display) - return self.parsers - - def configure_grid(self, g): - super(VendorCatalogsView, 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) - - def get_instance_title(self, batch): - return unicode(batch.vendor) - - def configure_fieldset(self, fs): - fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer) - fs.filename.set(label="Catalog File") - - if self.creating: - parser_options = [(p.display, p.key) for p in self.get_parsers()] - parser_options.insert(0, ("(please choose)", '')) - fs.parser_key.set(renderer=formalchemy.fields.SelectFieldRenderer, - options=parser_options, label="File Type") - fs.configure( - include=[ - fs.filename, - fs.parser_key, - fs.vendor, - ]) - - else: - fs.configure( - include=[ - fs.vendor.readonly(), - fs.filename, - fs.effective.readonly(), - fs.created, - fs.created_by, - fs.executed, - fs.executed_by, - ]) - - def get_batch_kwargs(self, batch): - kwargs = super(VendorCatalogsView, self).get_batch_kwargs(batch) - kwargs['parser_key'] = batch.parser_key - if batch.vendor: - kwargs['vendor'] = batch.vendor - elif batch.vendor_uuid: - kwargs['vendor_uuid'] = batch.vendor_uuid - return kwargs - - def configure_row_grid(self, g): - super(VendorCatalogsView, self).configure_row_grid(g) - g.set_label('upc', "UPC") - g.set_label('brand_name', "Brand") - g.set_label('old_unit_cost', "Old Cost") - g.set_label('unit_cost', "New Cost") - g.set_label('unit_cost_diff', "Diff.") - - def row_grid_extra_class(self, row, i): - if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: - return 'warning' - if row.status_code in (row.STATUS_NEW_COST, row.STATUS_UPDATE_COST): - return 'notice' - - 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 +import warnings def includeme(config): - VendorCatalogsView.defaults(config) + warnings.warn("The `tailbone.views.vendors.catalogs` module is deprecated, " + "please use `tailbone.views.batch.vendorcatalog` instead.", + DeprecationWarning, stacklevel=2) + config.include('tailbone.views.batch.vendorcatalog') diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 16ee11dd..addf153c 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.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,84 +24,226 @@ Vendor Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model -from tailbone import forms +from webhelpers2.html import tags + +from tailbone.views import MasterView from tailbone.db import Session -from tailbone.views import MasterView2 as MasterView, AutocompleteView -class VendorsView(MasterView): +class VendorView(MasterView): """ Master view for the Vendor class. """ model_class = model.Vendor has_versions = True + touchable = True + results_downloadable = True + supports_autocomplete = True + configurable = True + + labels = { + 'id': "ID", + 'default_phone': "Phone Number", + 'default_email': "Default Email", + } grid_columns = [ 'id', 'name', + 'abbreviation', 'phone', 'email', 'contact', + 'terms', + ] + + form_fields = [ + 'id', + 'name', + 'abbreviation', + 'special_discount', + 'lead_time_days', + 'order_interval_days', + 'default_phone', + 'default_email', + 'orders_email', + 'contact', + 'terms', ] def configure_grid(self, g): - super(VendorsView, self).configure_grid(g) + super().configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'name' + g.set_sort_defaults('name') - g.set_label('id', "ID") g.set_label('phone', "Phone Number") g.set_label('email', "Email Address") g.set_link('id') g.set_link('name') + g.set_link('abbreviation') - def configure_fieldset(self, fs): - fs.append(forms.AssociationProxyField('contact')) - fs.configure( - include=[ - fs.id.label("ID"), - fs.name, - fs.special_discount, - fs.lead_time_days.label("Lead Time in Days"), - fs.order_interval_days.label("Order Interval in Days"), - fs.phone.label("Phone Number").readonly(), - fs.email.label("Email Address").readonly(), - fs.contact.with_renderer(forms.PersonFieldRenderer).readonly(), - ]) + def configure_form(self, f): + super().configure_form(f) + app = self.get_rattail_app() + vendor = f.model_instance + + f.set_type('lead_time_days', 'quantity') + f.set_type('order_interval_days', 'quantity') + + # default_phone + f.set_renderer('default_phone', self.render_default_phone) + if not self.creating and vendor.phones: + f.set_default('default_phone', vendor.phones[0].number) + + # default_email + f.set_renderer('default_email', self.render_default_email) + if not self.creating and vendor.emails: + f.set_default('default_email', vendor.emails[0].address) + + # orders_email + f.set_renderer('orders_email', self.render_orders_email) + if not self.creating and vendor.emails: + f.set_default('orders_email', app.get_contact_email_address(vendor, type_='Orders') or '') + + # contact + if self.creating: + f.remove_field('contact') + else: + f.set_readonly('contact') + f.set_renderer('contact', self.render_contact) + + def objectify(self, form, data=None): + if data is None: + data = form.validated + 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 = app.get_contact_email(vendor, type_='Orders') + if address: + if email: + if email.address != address: + email.address = address + else: + vendor.add_email_address(address, type='Orders') + elif email: + vendor.emails.remove(email) + + return vendor + + def render_default_email(self, vendor, field): + if vendor.emails: + return vendor.emails[0].address + + def render_orders_email(self, vendor, field): + app = self.get_rattail_app() + return app.get_contact_email_address(vendor, type_='Orders') + + def render_default_phone(self, vendor, field): + if vendor.phones: + return vendor.phones[0].number + + def render_contact(self, vendor, field): + person = vendor.contact + if not person: + return "" + text = str(person) + url = self.request.route_url('people.view', uuid=person.uuid) + return tags.link_to(text, url) def before_delete(self, vendor): # Remove all product costs. - q = Session.query(model.ProductCost).filter( + q = self.Session.query(model.ProductCost).filter( model.ProductCost.vendor == vendor) for cost in q: - Session.delete(cost) + 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 [ -class VendorsAutocomplete(AutocompleteView): + # display + {'section': 'rattail', + 'option': 'vendors.choice_uses_dropdown', + 'type': bool}, + ] - mapped_class = model.Vendor - fieldname = 'name' + 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): - - # autocomplete - config.add_route('vendors.autocomplete', '/vendors/autocomplete') - config.add_view(VendorsAutocomplete, route_name='vendors.autocomplete', - renderer='json', permission='vendors.list') - - VendorsView.defaults(config) + defaults(config) diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index c3e01793..40fe0365 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -21,137 +21,16 @@ # ################################################################################ """ -Views for maintaining vendor invoices +(Deprecated!) Views for maintaining vendor invoices + +Please use `tailbone.views.batch.vendorinvoice` instead. """ -from __future__ import unicode_literals, absolute_import - -from rattail.db import model, api -from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser - -import formalchemy - -from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView2 as FileBatchMasterView - - -class VendorInvoicesView(FileBatchMasterView): - """ - Master view for vendor invoice batches. - """ - model_class = model.VendorInvoice - model_row_class = model.VendorInvoiceRow - default_handler_spec = 'rattail.batch.vendorinvoice:VendorInvoiceHandler' - url_prefix = '/vendors/invoices' - - grid_columns = [ - 'created', - 'created_by', - 'vendor', - 'filename', - 'executed', - ] - - row_grid_columns = [ - 'sequence', - 'upc', - 'brand_name', - 'description', - 'size', - 'vendor_code', - 'shipped_cases', - 'shipped_units', - 'unit_cost', - 'status_code', - ] - - def get_instance_title(self, batch): - return unicode(batch.vendor) - - def configure_grid(self, g): - super(VendorInvoicesView, 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) - - def configure_fieldset(self, fs): - fs.purchase_order_number.set(label=self.handler.po_number_title) - fs.purchase_order_number.set(validate=self.validate_po_number) - fs.filename.set(label="Invoice File") - - fs.configure( - include=[ - fs.vendor.readonly(), - fs.filename, - fs.parser_key, - fs.purchase_order_number, - fs.invoice_date.readonly(), - fs.created, - fs.created_by, - fs.executed, - fs.executed_by, - ]) - - if self.creating: - parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) - parser_options = [(p.display, p.key) for p in parsers] - parser_options.insert(0, ("(please choose)", '')) - fs.parser_key.set(label="File Type", - renderer=formalchemy.fields.SelectFieldRenderer, - options=parser_options) - del fs.vendor - del fs.invoice_date - - else: - del fs.parser_key - - def validate_po_number(self, value, field): - """ - Let the invoice handler in effect determine if the user-provided - purchase order number is valid. - """ - parser_key = field.parent.parser_key.value - if not parser_key: - raise formalchemy.ValidationError("Cannot validate PO number until File Type is chosen") - parser = require_invoice_parser(parser_key) - vendor = api.get_vendor(Session(), parser.vendor_key) - try: - self.handler.validate_po_number(value, vendor) - except ValueError as error: - raise formalchemy.ValidationError(unicode(error)) - - def get_batch_kwargs(self, batch): - kwargs = super(VendorInvoicesView, self).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(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 - batch.vendor = vendor - return True - - def configure_row_grid(self, g): - super(VendorInvoicesView, self).configure_row_grid(g) - g.set_label('upc', "UPC") - g.set_label('brand_name', "Brand") - g.set_label('shipped_cases', "Cases") - g.set_label('shipped_units', "Units") - - def row_grid_extra_class(self, row, i): - if row.status_code in (row.STATUS_NOT_IN_DB, - row.STATUS_COST_NOT_IN_DB, - row.STATUS_NO_CASE_QUANTITY): - return 'warning' - if row.status_code in (row.STATUS_NOT_IN_PURCHASE, - row.STATUS_NOT_IN_INVOICE, - row.STATUS_DIFFERS_FROM_PURCHASE): - return 'notice' +import warnings def includeme(config): - VendorInvoicesView.defaults(config) + warnings.warn("The `tailbone.views.vendors.invoices` module is deprecated, " + "please use `tailbone.views.batch.vendorinvoice` instead.", + 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 new file mode 100644 index 00000000..d0edb412 --- /dev/null +++ b/tailbone/webapi.py @@ -0,0 +1,118 @@ +# -*- 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 +""" + +import simplejson + +from cornice.renderer import CorniceRenderer +from pyramid.config import Configurator + +from tailbone import app +from tailbone.auth import TailboneSecurityPolicy +from tailbone.providers import get_all_providers + + +def make_rattail_config(settings): + """ + Make a Rattail config object from the given settings. + """ + rattail_config = app.make_rattail_config(settings) + return rattail_config + + +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_security_policy(TailboneSecurityPolicy(api_mode=True)) + + # always require CSRF token protection + pyramid_config.set_default_csrf_options(require_csrf=True, + token='_csrf', + header='X-XSRF-TOKEN') + + # bring in some Pyramid goodies + pyramid_config.include('tailbone.beaker') + 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: + import pyramid_retry + except ImportError: + pass + 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_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, views='tailbone.api', **settings): + """ + This function returns a Pyramid WSGI application. + """ + rattail_config = make_rattail_config(settings) + pyramid_config = make_pyramid_config(settings) + + # 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 new file mode 100644 index 00000000..6983dbea --- /dev/null +++ b/tasks.py @@ -0,0 +1,48 @@ +# -*- 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/>. +# +################################################################################ +""" +Tasks for Tailbone +""" + +import os +import shutil + +from invoke import task + + +@task +def release(c, skip_tests=False): + """ + Release a new version of 'Tailbone'. + """ + if not skip_tests: + c.run('pytest') + + if os.path.exists('dist'): + shutil.rmtree('dist') + if os.path.exists('Tailbone.egg-info'): + shutil.rmtree('Tailbone.egg-info') + + c.run('python -m build --sdist') + + c.run('twine upload dist/*') 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/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/forms/test_simpleform.py b/tests/forms/test_simpleform.py deleted file mode 100644 index 6108a4b3..00000000 --- a/tests/forms/test_simpleform.py +++ /dev/null @@ -1,25 +0,0 @@ - -from mock import Mock - -from .. import TestCase -from tailbone.forms import simpleform - - -class FormRendererTests(TestCase): - - def test_field_div(self): - form = Mock() - form.errors_for = Mock(return_value=[]) - renderer = simpleform.FormRenderer(form) - renderer.field_div('test', '<p>test</p>') - - def test_field_div_with_errors(self): - form = Mock() - form.errors_for = Mock(return_value=["Bogus testing error."]) - renderer = simpleform.FormRenderer(form) - renderer.field_div('test', '<p>test</p>') - - def test_referrer_field(self): - form = Mock() - renderer = simpleform.FormRenderer(form) - renderer.referrer_field() 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 c5180815..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,23 +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')) - def test_settings_arg_may_override_config_and_engines(self): - rattail_config = RattailConfig() - engine = create_engine('sqlite://') - result = app.make_rattail_config({ - 'rattail_config': rattail_config, - 'rattail_engines': {'default': engine}}) - self.assertTrue(result is rattail_config) - self.assertTrue(RattailSession.kw['bind'] is engine) - self.assertTrue(TailboneSession.bind is engine) + +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_root.py b/tests/test_root.py deleted file mode 100644 index 40363b88..00000000 --- a/tests/test_root.py +++ /dev/null @@ -1,11 +0,0 @@ - -from . import TestCase - - -class RootTests(TestCase): - """ - Test root module. - """ - - def test_includeme(self): - self.config.include('tailbone') diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py index 4317a262..81bc2869 100644 --- a/tests/test_subscribers.py +++ b/tests/test_subscribers.py @@ -1,39 +1,58 @@ +# -*- coding: utf-8; -*- -from unittest import TestCase +from unittest.mock import MagicMock -from mock import Mock from pyramid import testing -from tailbone import subscribers +from tailbone import subscribers as mod +from tests.util import DataTestCase -class SubscribersTests(TestCase): +class TestNewRequest(DataTestCase): - def test_before_render(self): - event = Mock() - event.__setitem__ = Mock() - subscribers.before_render(event) + def setUp(self): + self.setup_db() + self.request = self.make_request() + self.pyramid_config = testing.setUp(request=self.request, settings={ + 'wutta_config': self.config, + }) - -class TestAddRattailConfigAttributeToRequest(TestCase): - - def test_nothing_is_done_if_no_config_in_registry_settings(self): - request = testing.DummyRequest() - config = testing.setUp(request=request) - self.assertFalse('rattail_config' in request.registry.settings) - self.assertFalse(hasattr(request, 'rattail_config')) - event = Mock(request=request) - subscribers.add_rattail_config_attribute_to_request(event) - self.assertFalse(hasattr(request, 'rattail_config')) + def tearDown(self): + self.teardown_db() testing.tearDown() - def test_attribute_added_if_config_present_in_registry_settings(self): - rattail_config = Mock() - request = testing.DummyRequest() - config = testing.setUp(request=request, settings={'rattail_config': rattail_config}) - self.assertTrue('rattail_config' in request.registry.settings) - self.assertFalse(hasattr(request, 'rattail_config')) - event = Mock(request=request) - subscribers.add_rattail_config_attribute_to_request(event) - self.assertTrue(request.rattail_config is rattail_config) - 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/test_views.py b/tests/test_views.py deleted file mode 100644 index 648bdf7d..00000000 --- a/tests/test_views.py +++ /dev/null @@ -1,11 +0,0 @@ - -from . import TestCase - - -class ViewTests(TestCase): - """ - Test root views module. - """ - - def test_includeme(self): - self.config.include('tailbone.views') 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_autocomplete.py b/tests/views/test_autocomplete.py deleted file mode 100644 index dc630af4..00000000 --- a/tests/views/test_autocomplete.py +++ /dev/null @@ -1,95 +0,0 @@ - -from mock import Mock -from pyramid import testing - -from .. import TestCase, mock_query -from tailbone.views import autocomplete - - -class BareAutocompleteViewTests(TestCase): - - def view(self, **kwargs): - request = testing.DummyRequest(**kwargs) - return autocomplete.AutocompleteView(request) - - def test_attributes(self): - view = self.view() - self.assertRaises(AttributeError, getattr, view, 'mapped_class') - self.assertRaises(AttributeError, getattr, view, 'fieldname') - - def test_filter_query(self): - view = self.view() - query = Mock() - filtered = view.filter_query(query) - self.assertTrue(filtered is query) - - def test_make_query(self): - view = self.view() - # No mapped_class defined for view. - self.assertRaises(AttributeError, view.make_query, 'test') - - def test_query(self): - view = self.view() - query = Mock() - view.make_query = Mock(return_value=query) - filtered = view.query('test') - self.assertTrue(filtered is query) - - def test_display(self): - view = self.view() - instance = Mock() - # No fieldname defined for view. - self.assertRaises(AttributeError, view.display, instance) - - def test_call(self): - # Empty or missing query term yields empty list. - view = self.view(params={}) - self.assertEqual(view(), []) - view = self.view(params={'term': None}) - self.assertEqual(view(), []) - view = self.view(params={'term': ''}) - self.assertEqual(view(), []) - view = self.view(params={'term': '\t'}) - self.assertEqual(view(), []) - # No mapped_class defined for view. - view = self.view(params={'term': 'bogus'}) - self.assertRaises(AttributeError, view) - - -class SampleAutocompleteViewTests(TestCase): - - def setUp(self): - super(SampleAutocompleteViewTests, self).setUp() - self.Session_query = autocomplete.Session.query - self.query = mock_query() - autocomplete.Session.query = self.query - - def tearDown(self): - super(SampleAutocompleteViewTests, self).tearDown() - autocomplete.Session.query = self.Session_query - - def view(self, **kwargs): - request = testing.DummyRequest(**kwargs) - view = autocomplete.AutocompleteView(request) - view.mapped_class = Mock() - view.fieldname = 'thing' - return view - - def test_make_query(self): - view = self.view() - view.mapped_class.thing.ilike.return_value = 'whatever' - self.assertTrue(view.make_query('test') is self.query) - view.mapped_class.thing.ilike.assert_called_with('%test%') - self.query.filter.assert_called_with('whatever') - self.query.order_by.assert_called_with(view.mapped_class.thing) - - def test_call(self): - self.query.all.return_value = [ - Mock(uuid='1', thing='first'), - Mock(uuid='2', thing='second'), - ] - view = self.view(params={'term': 'bogus'}) - self.assertEqual(view(), [ - {'label': 'first', 'value': '1'}, - {'label': 'second', 'value': '2'}, - ]) 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 f1e1797f..3896befb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,26 +1,19 @@ + [tox] -envlist = py26, py27 +envlist = py38, py39, py310, py311 [testenv] -deps = - coverage - fixture - mock - nose -commands = - pip install --upgrade Tailbone rattail[bouncer] rattail-tempmon - nosetests {posargs} +deps = rattail-tempmon +extras = tests +commands = pytest {posargs} [testenv:coverage] -basepython = python -commands = - pip install --upgrade Tailbone rattail[bouncer] rattail-tempmon - nosetests {posargs:--with-coverage --cover-html-dir={envtmpdir}/coverage} +basepython = python3 +extras = tests +commands = pytest --cov=tailbone --cov-report=html [testenv:docs] -basepython = python -deps = Sphinx +basepython = python3 changedir = docs -commands = - pip install --upgrade Tailbone rattail[bouncer] 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