diff --git a/.gitignore b/.gitignore index 906dc226..b3006f90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ +*~ +*.pyc .coverage .tox/ +dist/ docs/_build/ htmlcov/ Tailbone.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c974b3a6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,683 @@ + +# Changelog +All notable changes to Tailbone will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## v0.22.7 (2025-02-19) + +### Fix + +- stop using old config for logo image url on login page +- fix warning msg for deprecated Grid param + +## v0.22.6 (2025-02-01) + +### Fix + +- register vue3 form component for products -> make batch + +## v0.22.5 (2024-12-16) + +### Fix + +- whoops this is latest rattail +- require newer rattail lib +- require newer wuttaweb +- let caller request safe HTML literal for rendered grid table + +## v0.22.4 (2024-11-22) + +### Fix + +- avoid error in product search for duplicated key +- use vmodel for confirm password widget input + +## v0.22.3 (2024-11-19) + +### Fix + +- avoid error for trainwreck query when not a customer + +## v0.22.2 (2024-11-18) + +### Fix + +- use local/custom enum for continuum operations +- add basic master view for Product Costs +- show continuum operation type when viewing version history +- always define `app` attr for ViewSupplement +- avoid deprecated import + +## v0.22.1 (2024-11-02) + +### Fix + +- fix submit button for running problem report +- avoid deprecated grid method + +## v0.22.0 (2024-10-22) + +### Feat + +- add support for new ordering batch from parsed file + +### Fix + +- avoid deprecated method to suggest username + +## v0.21.11 (2024-10-03) + +### Fix + +- custom method for adding grid action +- become/stop root should redirect to previous url + +## v0.21.10 (2024-09-15) + +### Fix + +- update project repo links, kallithea -> forgejo +- use better icon for submit button on login page +- wrap notes text for batch view +- expose datasync consumer batch size via configure page + +## v0.21.9 (2024-08-28) + +### Fix + +- render custom attrs in form component tag + +## v0.21.8 (2024-08-28) + +### Fix + +- ignore session kwarg for `MasterView.make_row_grid()` + +## v0.21.7 (2024-08-28) + +### Fix + +- avoid error when form value cannot be obtained + +## v0.21.6 (2024-08-28) + +### Fix + +- avoid error when grid value cannot be obtained + +## v0.21.5 (2024-08-28) + +### Fix + +- set empty string for "-new-" file configure option + +## v0.21.4 (2024-08-26) + +### Fix + +- handle differing email profile keys for appinfo/configure + +## v0.21.3 (2024-08-26) + +### Fix + +- show non-standard config values for app info configure email + +## v0.21.2 (2024-08-26) + +### Fix + +- refactor waterpark base template to use wutta feedback component +- fix input/output file upload feature for configure pages, per oruga +- tweak how grid data translates to Vue template context +- merge filters into main grid template +- add basic wutta view for users +- some fixes for wutta people view +- various fixes for waterpark theme +- avoid deprecated `component` form kwarg + +## v0.21.1 (2024-08-22) + +### Fix + +- misc. bugfixes per recent changes + +## v0.21.0 (2024-08-22) + +### Feat + +- move "most" filtering logic for grid class to wuttaweb +- inherit from wuttaweb templates for home, login pages +- inherit from wuttaweb for AppInfoView, appinfo/configure template +- add "has output file templates" config option for master view + +### Fix + +- change grid reset-view param name to match wuttaweb +- move "searchable columns" grid feature to wuttaweb +- use wuttaweb to get/render csrf token +- inherit from wuttaweb for appinfo/index template +- prefer wuttaweb config for "home redirect to login" feature +- fix master/index template rendering for waterpark theme +- fix spacing for navbar logo/title in waterpark theme + +## v0.20.1 (2024-08-20) + +### Fix + +- fix default filter verbs logic for workorder status + +## v0.20.0 (2024-08-20) + +### Feat + +- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy +- refactor templates to simplify base/page/form structure + +### Fix + +- avoid deprecated reference to app db engine + +## v0.19.3 (2024-08-19) + +### Fix + +- add pager stats to all grid vue data (fixes view history) + +## v0.19.2 (2024-08-19) + +### Fix + +- sort on frontend for appinfo package listing grid +- prefer attr over key lookup when getting model values +- replace all occurrences of `component_studly` => `vue_component` + +## v0.19.1 (2024-08-19) + +### Fix + +- fix broken user auth for web API app + +## v0.19.0 (2024-08-18) + +### Feat + +- move multi-column grid sorting logic to wuttaweb +- move single-column grid sorting logic to wuttaweb + +### Fix + +- fix misc. errors in grid template per wuttaweb +- fix broken permission directives in web api startup + +## v0.18.0 (2024-08-16) + +### Feat + +- move "basic" grid pagination logic to wuttaweb +- inherit from wutta base class for Grid +- inherit most logic from wuttaweb, for GridAction + +### Fix + +- avoid route error in user view, when using wutta people view +- fix some more wutta compat for base template + +## v0.17.0 (2024-08-15) + +### Feat + +- use wuttaweb for `get_liburl()` logic + +## v0.16.1 (2024-08-15) + +### Fix + +- improve wutta People view a bit +- update references to `get_class_hierarchy()` +- tweak template for `people/view_profile` per wutta compat + +## v0.16.0 (2024-08-15) + +### Feat + +- add first wutta-based master, for PersonView +- refactor forms/grids/views/templates per wuttaweb compat + +## v0.15.6 (2024-08-13) + +### Fix + +- avoid `before_render` subscriber hook for web API +- simplify verbiage for batch execution panel + +## v0.15.5 (2024-08-09) + +### Fix + +- assign convenience attrs for all views (config, app, enum, model) + +## v0.15.4 (2024-08-09) + +### Fix + +- avoid bug when checking current theme + +## v0.15.3 (2024-08-08) + +### Fix + +- fix timepicker `parseTime()` when value is null + +## v0.15.2 (2024-08-06) + +### Fix + +- use auth handler, avoid legacy calls for role/perm checks + +## v0.15.1 (2024-08-05) + +### Fix + +- move magic `b` template context var to wuttaweb + +## v0.15.0 (2024-08-05) + +### Feat + +- move more subscriber logic to wuttaweb + +### Fix + +- use wuttaweb logic for `util.get_form_data()` + +## v0.14.5 (2024-08-03) + +### Fix + +- use auth handler instead of deprecated auth functions +- avoid duplicate `partial` param when grid reloads data + +## v0.14.4 (2024-07-18) + +### Fix + +- fix more settings persistence bug(s) for datasync/configure +- fix modals for luigi tasks page, per oruga + +## v0.14.3 (2024-07-17) + +### Fix + +- fix auto-collapse title for viewing trainwreck txn +- allow auto-collapse of header when viewing trainwreck txn + +## v0.14.2 (2024-07-15) + +### Fix + +- add null menu handler, for use with API apps + +## v0.14.1 (2024-07-14) + +### Fix + +- update usage of auth handler, per rattail changes +- fix model reference in menu handler +- fix bug when making "integration" menus + +## v0.14.0 (2024-07-14) + +### Feat + +- move core menu logic to wuttaweb + +## v0.13.2 (2024-07-13) + +### Fix + +- fix logic bug for datasync/config settings save + +## v0.13.1 (2024-07-13) + +### Fix + +- fix settings persistence bug(s) for datasync/configure page + +## v0.13.0 (2024-07-12) + +### Feat + +- begin integrating WuttaWeb as upstream dependency + +### Fix + +- cast enum as list to satisfy deform widget + +## v0.12.1 (2024-07-11) + +### Fix + +- refactor `config.get_model()` => `app.model` + +## v0.12.0 (2024-07-09) + +### Feat + +- drop python 3.6 support, use pyproject.toml (again) + +## v0.11.10 (2024-07-05) + +### Fix + +- make the Members tab optional, for profile view + +## v0.11.9 (2024-07-05) + +### Fix + +- do not show flash message when changing app theme + +- improve collapse panels for butterball theme + +- expand input for butterball theme + +- add xref button to customer profile, for trainwreck txn view + +- add optional Transactions tab for profile view + +## v0.11.8 (2024-07-04) + +### Fix + +- fix grid action icons for datasync/configure, per oruga + +- allow view supplements to add extra links for profile employee tab + +- leverage import handler method to determine command/subcommand + +- add tool to make user account from profile view + +## v0.11.7 (2024-07-04) + +### Fix + +- add stacklevel to deprecation warnings + +- require zope.sqlalchemy >= 1.5 + +- include edit profile email/phone dialogs only if user has perms + +- allow view supplements to add to profile member context + +- cast enum as list to satisfy deform widget + +- expand POD image URL setting input + +## v0.11.6 (2024-07-01) + +### Fix + +- set explicit referrer when changing dbkey + +- remove references, dependency for `six` package + +## v0.11.5 (2024-06-30) + +### Fix + +- allow comma in numeric filter input + +- add custom url prefix if needed, for fanstatic + +- use vue 3.4.31 and oruga 0.8.12 by default + +## v0.11.4 (2024-06-30) + +### Fix + +- start/stop being root should submit POST instead of GET + +- require vendor when making new ordering batch via api + +- don't escape each address for email attempts grid + +## v0.11.3 (2024-06-28) + +### Fix + +- add link to "resolved by" user for pending products + +- handle error when merging 2 records fails + +## v0.11.2 (2024-06-18) + +### Fix + +- hide certain custorder settings if not applicable + +- use different logic for buefy/oruga for product lookup keydown + +- product records should be touchable + +- show flash error message if resolve pending product fails + +## v0.11.1 (2024-06-14) + +### Fix + +- revert back to setup.py + setup.cfg + +## v0.11.0 (2024-06-10) + +### Feat + +- switch from setup.cfg to pyproject.toml + hatchling + +## v0.10.16 (2024-06-10) + +### Feat + +- standardize how app, package versions are determined + +### Fix + +- avoid deprecated config methods for app/node title + +## v0.10.15 (2024-06-07) + +### Fix + +- do *not* Use `pkg_resources` to determine package versions + +## v0.10.14 (2024-06-06) + +### Fix + +- use `pkg_resources` to determine package versions + +## v0.10.13 (2024-06-06) + +### Feat + +- remove old/unused scaffold for use with `pcreate` + +- add 'fanstatic' support for sake of libcache assets + +## v0.10.12 (2024-06-04) + +### Feat + +- require pyramid 2.x; remove 1.x-style auth policies + +- remove version cap for deform + +- set explicit referrer when changing app theme + +- add `<b-tooltip>` component shim + +- include extra styles from `base_meta` template for butterball + +- include butterball theme by default for new apps + +### Fix + +- fix product lookup component, per butterball + +## v0.10.11 (2024-06-03) + +### Feat + +- fix vue3 refresh bugs for various views + +- fix grid bug for tempmon appliance view, per oruga + +- fix ordering worksheet generator, per butterball + +- fix inventory worksheet generator, per butterball + +## v0.10.10 (2024-06-03) + +### Feat + +- more butterball fixes for "view profile" template + +### Fix + +- fix focus for `<b-select>` shim component + +## v0.10.9 (2024-06-03) + +### Feat + +- let master view control context menu items for page + +- fix the "new custorder" page for butterball + +### Fix + +- fix panel style for PO vs. Invoice breakdown in receiving batch + +## v0.10.8 (2024-06-02) + +### Feat + +- add styling for checked grid rows, per oruga/butterball + +- fix product view template for oruga/butterball + +- allow per-user custom styles for butterball + +- use oruga 0.8.9 by default + +## v0.10.7 (2024-06-01) + +### Feat + +- add setting to allow decimal quantities for receiving + +- log error if registry has no rattail config + +- add column filters for import/export main grid + +- escape all unsafe html for grid data + +- add speedbumps for delete, set preferred email/phone in profile view + +- fix file upload widget for oruga + +### Fix + +- fix overflow when instance header title is too long (butterball) + +## v0.10.6 (2024-05-29) + +### Feat + +- add way to flag organic products within lookup dialog + +- expose db picker for butterball theme + +- expose quickie lookup for butterball theme + +- fix basic problems with people profile view, per butterball + +## v0.10.5 (2024-05-29) + +### Feat + +- add `<tailbone-timepicker>` component for oruga + +## v0.10.4 (2024-05-12) + +### Fix + +- fix styles for grid actions, per butterball + +## v0.10.3 (2024-05-10) + +### Fix + +- fix bug with grid date filters + +## v0.10.2 (2024-05-08) + +### Feat + +- remove version restriction for pyramid_beaker dependency + +- rename some attrs etc. for buefy components used with oruga + +- fix "tools" helper for receiving batch view, per oruga + +- more data type fixes for ``<tailbone-datepicker>`` + +- fix "view receiving row" page, per oruga + +- tweak styles for grid action links, per butterball + +### Fix + +- fix employees grid when viewing department (per oruga) + +- fix login "enter" key behavior, per oruga + +- fix button text for autocomplete + +## v0.10.1 (2024-04-28) + +### Feat + +- sort list of available themes + +- update various icon names for oruga compatibility + +- show "View This" button when cloning a record + +- stop including 'falafel' as available theme + +### Fix + +- fix vertical alignment in main menu bar, for butterball + +- fix upgrade execution logic/UI per oruga + +## v0.10.0 (2024-04-28) + +This version bump is to reflect adding support for Vue 3 + Oruga via +the 'butterball' theme. There is likely more work to be done for that +yet, but it mostly works at this point. + +### Feat + +- misc. template and view logic tweaks (applicable to all themes) for + better patterns, consistency etc. + +- add initial support for Vue 3 + Oruga, via "butterball" theme + + +## Older Releases + +Please see `docs/OLDCHANGES.rst` for older release notes. diff --git a/CHANGES.rst b/CHANGES.rst deleted file mode 100644 index c27d2f2a..00000000 --- a/CHANGES.rst +++ /dev/null @@ -1,1245 +0,0 @@ - -CHANGELOG -========= - -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/COPYING.txt b/COPYING.txt index dba13ed2..94a9ed02 100644 --- a/COPYING.txt +++ b/COPYING.txt @@ -1,5 +1,5 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> Everyone is permitted to copy and distribute verbatim copies @@ -7,15 +7,17 @@ Preamble - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. + The GNU General Public License is a free, copyleft license for +software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to +the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free -software for all its users. +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you @@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. @@ -60,7 +72,7 @@ modification follow. 0. Definitions. - "This License" refers to version 3 of the GNU Affero General Public License. + "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. @@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. + 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single +under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General +Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published +GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's +versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. @@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) <year> <name of author> This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by + 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. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. + GNU General Public License for more details. - You should have received a copy of the GNU Affero General Public License + You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. Also add information on how to contact you by electronic and paper mail. - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see +For more information on this, and how to apply and follow the GNU GPL, see <http://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<http://www.gnu.org/philosophy/why-not-lgpl.html>. 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/grids.rst b/docs/api/grids.rst new file mode 100644 index 00000000..3799cbc8 --- /dev/null +++ b/docs/api/grids.rst @@ -0,0 +1,6 @@ + +``tailbone.grids`` +================== + +.. automodule:: tailbone.grids + :members: diff --git a/docs/api/newgrids.rst b/docs/api/newgrids.rst deleted file mode 100644 index b065d643..00000000 --- a/docs/api/newgrids.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. -*- coding: utf-8 -*- - -``tailbone.newgrids`` -===================== - -.. automodule:: tailbone.newgrids - :members: - -.. automodule:: tailbone.newgrids.alchemy - :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.rst b/docs/api/views/batch.rst index a98fc39d..344d6bd8 100644 --- a/docs/api/views/batch.rst +++ b/docs/api/views/batch.rst @@ -1,29 +1,5 @@ -.. -*- coding: utf-8 -*- ``tailbone.views.batch`` ======================== .. automodule:: tailbone.views.batch - -.. autoclass:: BatchGrid - :members: - -.. autoclass:: FileBatchGrid - :members: - -.. autoclass:: BatchCrud - :members: - -.. autoclass:: FileBatchCrud - :members: - -.. autoclass:: BatchRowGrid - :members: - -.. autoclass:: ProductBatchRowGrid - :members: - -.. autoclass:: BatchRowCrud - :members: - -.. autofunction:: defaults 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 319c6c44..e7de7170 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -1,4 +1,3 @@ -.. -*- coding: utf-8 -*- ``tailbone.views.master`` ========================= @@ -67,12 +66,61 @@ override when defining your subclass. .. attribute:: MasterView.grid_factory Factory callable to be used when creating new grid instances; defaults to - :class:`tailbone.newgrids.alchemy.AlchemyGrid`. + :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 dc624dab..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 execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# 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 -execfile(os.path.join(os.pardir, 'tailbone', '_version.py')) +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,234 +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 = 'alabaster' - -# 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 c5d30914..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/newgrids + 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/fabfile.py b/fabfile.py deleted file mode 100644 index 77a24a5e..00000000 --- a/fabfile.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ - -import shutil -from fabric.api import task, local - - -@task -def release(): - """ - Release a new version of 'Tailbone'. - """ - - shutil.rmtree('Tailbone.egg-info') - local('python setup.py sdist --formats=gztar register upload') diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a7214a8e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,103 @@ + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "Tailbone" +version = "0.22.7" +description = "Backoffice Web Application for Rattail" +readme = "README.md" +authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] +license = {text = "GNU GPL v3+"} +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Pyramid", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Office/Business", + "Topic :: Software Development :: Libraries :: Python Modules", +] +requires-python = ">= 3.8" +dependencies = [ + "asgiref", + "colander", + "ColanderAlchemy", + "cornice", + "cornice-swagger", + "deform", + "humanize", + "Mako", + "markdown", + "openpyxl", + "paginate", + "paginate_sqlalchemy", + "passlib", + "Pillow", + "pyramid>=2", + "pyramid_beaker", + "pyramid_deform", + "pyramid_exclog", + "pyramid_fanstatic", + "pyramid_mako", + "pyramid_retry", + "pyramid_tm", + "rattail[db,bouncer]>=0.20.1", + "sa-filters", + "simplejson", + "transaction", + "waitress", + "WebHelpers2", + "WuttaWeb>=0.21.0", + "zope.sqlalchemy>=1.5", +] + + +[project.optional-dependencies] +docs = ["Sphinx", "furo"] +tests = ["coverage", "mock", "pytest", "pytest-cov"] + + +[project.entry-points."paste.app_factory"] +main = "tailbone.app:main" +webapi = "tailbone.webapi:main" + + +[project.entry-points."rattail.cleaners"] +beaker = "tailbone.cleanup:BeakerCleaner" + + +[project.entry-points."rattail.config.extensions"] +tailbone = "tailbone.config:ConfigExtension" + + +[project.urls] +Homepage = "https://rattailproject.org" +Repository = "https://forgejo.wuttaproject.org/rattail/tailbone" +Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues" +Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md" + + +[tool.commitizen] +version_provider = "pep621" +tag_format = "v$version" +update_changelog_on_bump = true + + +[tool.nosetests] +nocapture = 1 +cover-package = "tailbone" +cover-erase = 1 +cover-html = 1 +cover-html-dir = "htmlcov" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7712ec72..00000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[nosetests] -nocapture = 1 -cover-package = tailbone -cover-erase = 1 -cover-html = 1 -cover-html-dir = htmlcov diff --git a/setup.py b/setup.py deleted file mode 100644 index 2d3f1bd9..00000000 --- a/setup.py +++ /dev/null @@ -1,165 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ - -from __future__ import unicode_literals - -import os.path -from setuptools import setup, find_packages - - -here = os.path.abspath(os.path.dirname(__file__)) -execfile(os.path.join(here, 'tailbone', '_version.py')) -README = open(os.path.join(here, 'README.rst')).read() - - -requires = [ - # - # Version numbers within comments below have specific meanings. - # Basically the 'low' value is a "soft low," and 'high' a "soft high." - # In other words: - # - # If either a 'low' or 'high' value exists, the primary point to be - # made about the value is that it represents the most current (stable) - # version available for the package (assuming typical public access - # methods) whenever this project was started and/or documented. - # Therefore: - # - # If a 'low' version is present, you should know that attempts to use - # versions of the package significantly older than the 'low' version - # may not yield happy results. (A "hard" high limit may or may not be - # indicated by a true version requirement.) - # - # Similarly, if a 'high' version is present, and especially if this - # project has laid dormant for a while, you may need to refactor a bit - # when attempting to support a more recent version of the package. (A - # "hard" low limit should be indicated by a true version requirement - # when a 'high' version is present.) - # - # In any case, developers and other users are encouraged to play - # outside the lines with regard to these soft limits. If bugs are - # encountered then they should be filed as such. - # - # package # low high - - # For now, let's restrict FormEncode to 1.2 since the 1.3 release - # introduces some deprecation warnings. Once we're running 1.2 everywhere - # in production, we can start looking at adding 1.3 support. - # TODO: Remove this restriction. - 'FormEncode<=1.2.99', # 1.2.4 1.2.6 - - # FormAlchemy 1.5 supports Python 3 but is being a little aggressive about - # it, for our needs...We'll have to stick with 1.4 for now. - u'FormAlchemy<=1.4.99', # 1.4.3 - - # Pyramid 1.3 introduced 'pcreate' command (and friends) to replace - # deprecated 'paster create' (and friends). - 'pyramid>=1.3a1', # 1.3b2 1.4.5 - - 'edbob', # 1.1.2 - 'humanize', # 0.5.1 - 'Mako', # 0.6.2 - 'pyramid_beaker>=0.6', # 0.6.1 - 'pyramid_debugtoolbar', # 1.0 - 'pyramid_exclog', # 0.6 - 'pyramid_simpleform', # 0.6.1 - 'pyramid_tm', # 0.3 - 'rattail[db,auth,bouncer]>=0.5.0', # 0.5.0 - 'transaction', # 1.2.0 - 'waitress', # 0.8.1 - 'WebHelpers', # 1.3 - 'zope.sqlalchemy', # 0.7 - - # TODO: Need to figure out what to do about this... - # # This is used to obtain POD image dimensions. - # 'PIL', # 1.1.7 - ] - - -extras = { - - 'docs': [ - # - # package # low high - - 'Sphinx', # 1.2 - ], - - 'tests': [ - # - # package # low high - - 'coverage', # 3.6 - 'fixture', # 1.5 - 'mock', # 1.0.1 - 'nose', # 1.3.0 - ], - } - - -setup( - name = "Tailbone", - version = __version__, - author = "Lance Edgar", - author_email = "lance@edbob.org", - url = "http://rattailproject.org/", - license = "GNU Affero GPL v3", - description = "Backoffice Web Application for Rattail", - long_description = README, - - classifiers = [ - 'Development Status :: 3 - Alpha', - 'Environment :: Web Environment', - 'Framework :: Pyramid', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU Affero General Public License v3', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Office/Business', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - - install_requires = requires, - extras_require = extras, - tests_require = ['Tailbone[tests]'], - test_suite = 'nose.collector', - - packages = find_packages(exclude=['tests.*', 'tests']), - include_package_data = True, - zip_safe = False, - - entry_points = { - - 'paste.app_factory': [ - 'main = tailbone.app:main', - ], - - 'rattail.config.extensions': [ - 'tailbone = tailbone.config:ConfigExtension', - ], - - }, -) diff --git a/tailbone/__init__.py b/tailbone/__init__.py index 23169426..fc93539e 100644 --- a/tailbone/__init__.py +++ b/tailbone/__init__.py @@ -1,24 +1,23 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2017 Lance Edgar # # This file is part of Rattail. # # Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ diff --git a/tailbone/_version.py b/tailbone/_version.py index 990f6718..7095f6c8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,9 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- -__version__ = u'0.5.25' +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + + +__version__ = version('Tailbone') diff --git a/tailbone/api/__init__.py b/tailbone/api/__init__.py new file mode 100644 index 00000000..1fae059f --- /dev/null +++ b/tailbone/api/__init__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API +""" + +from __future__ import unicode_literals, absolute_import + +from .core import APIView, api +from .master import APIMasterView, SortColumn +# TODO: remove this +from .master2 import APIMasterView2 + + +def includeme(config): + config.include('tailbone.api.common') + config.include('tailbone.api.auth') + config.include('tailbone.api.customers') + config.include('tailbone.api.upgrades') + config.include('tailbone.api.users') 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/api/batch/__init__.py b/tailbone/api/batch/__init__.py new file mode 100644 index 00000000..bdf58438 --- /dev/null +++ b/tailbone/api/batch/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2019 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Batches +""" + +from __future__ import unicode_literals, absolute_import + +from .core import APIBatchView, APIBatchRowView, BatchAPIMasterView 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/api/customers.py b/tailbone/api/customers.py new file mode 100644 index 00000000..85d28c24 --- /dev/null +++ b/tailbone/api/customers.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Customer Views +""" + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class CustomerView(APIMasterView): + """ + API views for Customer data + """ + model_class = model.Customer + collection_url_prefix = '/customers' + object_url_prefix = '/customer' + supports_autocomplete = True + autocomplete_fieldname = 'name' + + def normalize(self, customer): + return { + 'uuid': customer.uuid, + '_str': str(customer), + 'id': customer.id, + 'number': customer.number, + 'name': customer.name, + } + + +def defaults(config, **kwargs): + base = globals() + + CustomerView = kwargs.get('CustomerView', base['CustomerView']) + CustomerView.defaults(config) + + +def includeme(config): + defaults(config) 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/api/labels.py b/tailbone/api/labels.py new file mode 100644 index 00000000..8bc11f8f --- /dev/null +++ b/tailbone/api/labels.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Label Views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db.model import LabelProfile + +from tailbone.api import APIMasterView + + +class LabelProfileView(APIMasterView): + """ + API views for Label Profile data + """ + model_class = LabelProfile + collection_url_prefix = '/label-profiles' + object_url_prefix = '/label-profile' + + +def defaults(config, **kwargs): + base = globals() + + LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView']) + LabelProfileView.defaults(config) + + +def includeme(config): + defaults(config) 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/api/people.py b/tailbone/api/people.py new file mode 100644 index 00000000..f7c08dfa --- /dev/null +++ b/tailbone/api/people.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tailbone Web API - Person Views +""" + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class PersonView(APIMasterView): + """ + API views for Person data + """ + model_class = model.Person + permission_prefix = 'people' + collection_url_prefix = '/people' + object_url_prefix = '/person' + + def normalize(self, person): + return { + 'uuid': person.uuid, + '_str': str(person), + 'first_name': person.first_name, + 'last_name': person.last_name, + 'display_name': person.display_name, + } + + +def defaults(config, **kwargs): + base = globals() + + PersonView = kwargs.get('PersonView', base['PersonView']) + PersonView.defaults(config) + + +def includeme(config): + defaults(config) 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 7ac1520b..d2d0c5ef 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -1,55 +1,46 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Application Entry Point """ -from __future__ import unicode_literals, absolute_import - import os -import logging -import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker, scoped_session -from edbob.pyramid.forms.formalchemy import TemplateEngine +from wuttjamaican.util import parse_list -import rattail.db from rattail.config import make_config from rattail.exceptions import ConfigurationError -from rattail.db.util import get_engines -from rattail.db.continuum import configure_versioning -from rattail.db.types import GPCType -import formalchemy 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.forms import renderers - - -log = logging.getLogger(__name__) +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): @@ -59,40 +50,52 @@ def make_rattail_config(settings): rattail_config = settings.get('rattail_config') if not rattail_config: - # Initialize rattail config and embed it in the settings dict, to make it - # available to web requests later. - path = settings.get('edbob.config') + # initialize rattail config and embed in settings dict, to make + # available for web requests later + path = settings.get('rattail.config') if not path or not os.path.exists(path): - raise ConfigurationError("Please set 'edbob.config' in [app:main] section of config " + 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']) + # 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 @@ -102,53 +105,226 @@ 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) -def make_pyramid_config(settings): +class Root(dict): + """ + Root factory for Pyramid. This is necessary to make the current request + available to the authorization policy object, which needs it to check if + the current request "is root". + """ + + def __init__(self, request): + self.request = request + + +def make_pyramid_config(settings, configure_csrf=True): """ Make a Pyramid config object from the given settings. """ - config = Configurator(settings=settings) + rattail_config = settings['rattail_config'] - # Configure user authentication / authorization. - config.set_authentication_policy(SessionAuthenticationPolicy()) - config.set_authorization_policy(TailboneAuthorizationPolicy()) + config = settings.pop('pyramid_config', None) + if config: + config.set_root_factory(Root) + else: + + # declare this web app of the "classic" variety + settings.setdefault('tailbone.classic', 'true') + + # we want the new themes feature! + establish_theme(settings) + + settings.setdefault('fanstatic.versioning', 'true') + settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') + config = Configurator(settings=settings, root_factory=Root) + + # add rattail config directly to registry, for access throughout the app + config.registry['rattail_config'] = rattail_config + + # configure user authorization / authentication + config.set_security_policy(TailboneSecurityPolicy()) + + # maybe require CSRF token protection + if configure_csrf: + config.set_default_csrf_options(require_csrf=True, + token=csrf_token_name(rattail_config), + header=csrf_header_name(rattail_config)) # Bring in some Pyramid goodies. - config.include('pyramid_beaker') + 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. - formalchemy.config.engine = TemplateEngine() - formalchemy.FieldSet.default_renderers[sa.Boolean] = renderers.YesNoFieldRenderer - formalchemy.FieldSet.default_renderers[sa.Date] = renderers.DateFieldRenderer - formalchemy.FieldSet.default_renderers[sa.DateTime] = renderers.DateTimeFieldRenderer - formalchemy.FieldSet.default_renderers[sa.Time] = renderers.TimeFieldRenderer - formalchemy.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, adds the tween necessary for graceful handling of database restarts. """ - pyramid_config.add_tween('edbob.pyramid.tweens.sqlerror_tween_factory', + pyramid_config.add_tween('tailbone.tweens.sqlerror_tween_factory', under='pyramid_tm.tm_tween_factory') @@ -156,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 02d0cac2..95bf90ba 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -1,82 +1,133 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Authentication & Authorization """ -from __future__ import unicode_literals +import logging +import re -from edbob.util import prettify +from wuttjamaican.util import UNSPECIFIED -from rattail.db import model -from rattail.db.auth import has_permission - -from zope.interface import implementer -from pyramid.interfaces import IAuthorizationPolicy -from pyramid.security import Everyone, Authenticated +from pyramid.security import remember, forget +from wuttaweb.auth import WuttaSecurityPolicy from tailbone.db import Session -@implementer(IAuthorizationPolicy) -class TailboneAuthorizationPolicy(object): - - def permits(self, context, principals, permission): - for userid in principals: - if userid not in (Everyone, Authenticated): - user = Session.query(model.User).get(userid) - if user: - return has_permission(Session(), user, permission) - if Everyone in principals: - return has_permission(Session(), None, permission) - return False - - def principals_allowed_by_permission(self, context, permission): - raise NotImplementedError +log = logging.getLogger(__name__) -def add_permission_group(config, key, label=None, overwrite=True): +def login_user(request, user, timeout=UNSPECIFIED): """ - Add a permission group to the app configuration. + Perform the steps necessary to login the given user. Note that this + returns a ``headers`` dict which you should pass to the redirect. """ - 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) + config = request.rattail_config + app = config.get_app() + user.record_event(app.enum.USER_EVENT_LOGIN) + headers = remember(request, user.uuid) + 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 -def add_permission(config, groupkey, key, label=None): +def logout_user(request): """ - Add a permission to the app configuration. + Perform the logout action for the given request. Note that this returns a + ``headers`` dict which you should pass to the redirect. """ - 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) + app = request.rattail_config.get_app() + user = request.user + if user: + user.record_event(app.enum.USER_EVENT_LOGOUT) + request.session.delete() + request.session.invalidate() + headers = forget(request) + return headers + + +def session_timeout_for_user(config, user): + """ + Returns the "max" session timeout for the user, according to roles + """ + app = config.get_app() + auth = app.get_auth_handler() + + authenticated = auth.get_role_authenticated(Session()) + roles = user.roles + [authenticated] + timeouts = [role.session_timeout for role in roles + if role.session_timeout is not None] + + if timeouts and 0 not in timeouts: + return max(timeouts) + + +def set_session_timeout(request, timeout): + """ + Set the server-side session timeout to the given value. + """ + request.session['_timeout'] = timeout or None + + +class TailboneSecurityPolicy(WuttaSecurityPolicy): + + def __init__(self, db_session=None, api_mode=False, **kwargs): + kwargs['db_session'] = db_session or Session() + super().__init__(**kwargs) + self.api_mode = api_mode + + def load_identity(self, request): + config = request.registry.settings.get('rattail_config') + app = config.get_app() + user = None + + if self.api_mode: + + # determine/load user from header token if present + credentials = request.headers.get('Authorization') + if credentials: + match = re.match(r'^Bearer (\S+)$', credentials) + if match: + token = match.group(1) + auth = app.get_auth_handler() + user = auth.authenticate_user_token(self.db_session, token) + + if not user: + + # fetch user uuid from current session + uuid = self.session_helper.authenticated_userid(request) + if not uuid: + return + + # fetch user object from db + model = app.model + user = self.db_session.get(model.User, uuid) + if not user: + return + + # 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 new file mode 100644 index 00000000..25a450df --- /dev/null +++ b/tailbone/beaker.py @@ -0,0 +1,148 @@ +# -*- 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/>. +# +################################################################################ +""" +Custom sessions, based on Beaker + +Note that most of the code for this module was copied from the beaker and +pyramid_beaker projects. +""" + +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 +from pyramid_beaker import BeakerSessionFactoryConfig, set_cache_regions_from_settings + + +class TailboneSession(Session): + """ + Custom session class for Beaker, which overrides load() to add per-request + session timeout support. + """ + + 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, + **self.namespace_args) + now = time.time() + if self.use_cookies: + self.request['set_cookie'] = True + + self.namespace.acquire_read_lock() + timed_out = False + try: + self.clear() + try: + session_data = self.namespace['session'] + + 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 + if session_data is None: + session_data = { + '_creation_time': now, + '_accessed_time': now + } + self.is_new = True + except (KeyError, TypeError): + session_data = { + '_creation_time': now, + '_accessed_time': now + } + self.is_new = True + + if session_data is None or len(session_data) == 0: + session_data = { + '_creation_time': now, + '_accessed_time': now + } + self.is_new = True + + # TODO: sure would be nice if we could get this little bit of logic + # into the upstream Beaker package, as that would avoid the need + # 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: + # Properly set the last_accessed time, which is different + # than the *currently* _accessed_time + if self.is_new or '_accessed_time' not in session_data: + self.last_accessed = None + else: + self.last_accessed = session_data['_accessed_time'] + + # Update the current _accessed_time + session_data['_accessed_time'] = now + + self.update(session_data) + self.accessed_dict = session_data.copy() + finally: + self.namespace.release_read_lock() + if timed_out: + self.invalidate() + + +def session_factory_from_settings(settings): + """ Return a Pyramid session factory using Beaker session settings + supplied from a Paste configuration file""" + prefixes = ('session.', 'beaker.session.') + options = {} + + # Pull out any config args meant for beaker session. if there are any + for k, v in settings.items(): + for prefix in prefixes: + if k.startswith(prefix): + option_name = k[len(prefix):] + if option_name == 'cookie_on_exception': + v = asbool(v) + options[option_name] = v + + options = coerce_session_params(options) + options['session_class'] = TailboneSession + return BeakerSessionFactoryConfig(**options) + + +def includeme(config): + session_factory = session_factory_from_settings(config.registry.settings) + config.set_session_factory(session_factory) + set_cache_regions_from_settings(config.registry.settings) 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 6d9cf93a..8392ba0a 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -1,38 +1,39 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ 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 5bf79fe5..8b37f399 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -1,29 +1,27 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ - """ -Database Stuff +Database sessions etc. """ import sqlalchemy as sa @@ -35,20 +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)) +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 @@ -60,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: @@ -86,49 +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 __version__ - assert tuple(int(x) for x in __version__.split(".")) >= (0, 7), \ - "SQLAlchemy version 0.7 or greater required to use register()" - from sqlalchemy import event - ext = ZopeTransactionExtension( - initial_state=initial_state, + ext = ZopeTransactionEvents( + initial_state=initial_state, transaction_manager=transaction_manager, keep_session=keep_session, ) @@ -140,9 +197,10 @@ 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) -# TODO: We can probably assume a new SA version since we use Continuum now. -if tuple(int(x) for x in sa.__version__.split('.')) >= (0, 7): - register(Session) -else: - Session.configure(extension=ZopeTransactionExtension()) + +register(Session) +register(TempmonSession) +register(TrainwreckSession) diff --git a/tailbone/diffs.py b/tailbone/diffs.py new file mode 100644 index 00000000..2e582b15 --- /dev/null +++ b/tailbone/diffs.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Tools for displaying data diffs +""" + +import sqlalchemy as sa +import sqlalchemy_continuum as continuum + +from pyramid.renderers import render +from webhelpers2.html import HTML + + +class Diff(object): + """ + Core diff class. In sore need of documentation. + + You must provide the old and new data sets, and the set of + relevant fields as well, if they cannot be easily introspected. + + :param old_data: Dict of "old" data values. + + :param new_data: Dict of "old" data values. + + :param fields: Sequence of relevant field names. Note that + both data dicts are expected to have keys which match these + field names. If you do not specify the fields then they + will (hopefully) be introspected from the old or new data + sets; however this will not work if they are both empty. + + :param monospace: If true, this flag will cause the value + columns to be rendered in monospace font. This is assumed + to be helpful when comparing "raw" data values which are + shown as e.g. ``repr(val)``. + + :param enums: Optional dict of enums for use when displaying field + values. If specified, keys should be field names and values + should be enum dicts. + """ + + def __init__(self, old_data, new_data, columns=None, fields=None, enums=None, + render_field=None, render_value=None, nature='dirty', + monospace=False, extra_row_attrs=None): + self.old_data = old_data + self.new_data = new_data + self.columns = columns or ["field name", "old value", "new value"] + self.fields = fields or self.make_fields() + self.enums = enums or {} + self._render_field = render_field or self.render_field_default + self.render_value = render_value or self.render_value_default + self.nature = nature + self.monospace = monospace + self.extra_row_attrs = extra_row_attrs + + def make_fields(self): + return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower()) + + def old_value(self, field): + return self.old_data.get(field) + + def new_value(self, field): + return self.new_data.get(field) + + def values_differ(self, field): + return self.new_value(field) != self.old_value(field) + + def render_html(self, template='/diff.mako', **kwargs): + context = kwargs + context['diff'] = self + return HTML.literal(render(template, context)) + + def get_row_attrs(self, field): + """ + Returns a *rendered* set of extra attributes for the ``<tr>`` element + for the given field. May be an empty string, or a snippet of HTML + attribute syntax, e.g.: + + .. code-block:: none + + class="diff" foo="bar" + + If you wish to supply additional attributes, please define + :attr:`extra_row_attrs`, which can be either a static dict, or a + callable returning a dict. + """ + attrs = {} + if self.values_differ(field): + attrs['class'] = 'diff' + + if self.extra_row_attrs: + if callable(self.extra_row_attrs): + attrs.update(self.extra_row_attrs(field, attrs)) + else: + attrs.update(self.extra_row_attrs) + + return HTML.render_attrs(attrs) + + def render_field(self, field): + return self._render_field(field, self) + + def render_field_default(self, field, diff): + return field + + def render_value_default(self, field, value): + return repr(value) + + def render_old_value(self, field): + value = self.old_value(field) + return self.render_value(field, value) + + def render_new_value(self, field): + value = self.new_value(field) + return self.render_value(field, value) + + +class VersionDiff(Diff): + """ + Special diff class, for use with version history views. Note that + while based on :class:`Diff`, this class uses a different + signature for the constructor. + + :param version: Reference to a Continuum version record (object). + + :param \*args: Typical usage will not require positional args + beyond the ``version`` param, in which case ``old_data`` and + ``new_data`` params will be auto-determined based on the + ``version``. But if you specify positional args then nothing + automatic is done, they are passed as-is to the parent + :class:`Diff` constructor. + + :param \*\*kwargs: Remaining kwargs are passed as-is to the + :class:`Diff` constructor. + """ + + def __init__(self, version, *args, **kwargs): + self.version = version + self.mapper = sa.inspect(continuum.parent_class(type(self.version))) + self.version_mapper = sa.inspect(type(self.version)) + self.title = kwargs.pop('title', None) + + if 'nature' not in kwargs: + if version.previous and version.operation_type == continuum.Operation.DELETE: + kwargs['nature'] = 'deleted' + elif version.previous: + kwargs['nature'] = 'dirty' + else: + kwargs['nature'] = 'new' + + if 'fields' not in kwargs: + kwargs['fields'] = self.get_default_fields() + + if not args: + old_data = {} + new_data = {} + for field in kwargs['fields']: + if version.previous: + old_data[field] = getattr(version.previous, field) + new_data[field] = getattr(version, field) + args = (old_data, new_data) + + super().__init__(*args, **kwargs) + + def get_default_fields(self): + fields = sorted(self.version_mapper.columns.keys()) + + unwanted = [ + 'transaction_id', + 'end_transaction_id', + 'operation_type', + ] + + return [field for field in fields + if field not in unwanted] + + def render_version_value(self, field, value, version): + """ + Render the cell value text for the given version/field info. + + Note that this method is used to render both sides of the diff + (before and after values). + + :param field: Name of the field, as string. + + :param value: Raw value for the field, as obtained from ``version``. + + :param version: Reference to the Continuum version object. + + :returns: Rendered text as string, or ``None``. + """ + text = HTML.tag('span', c=[repr(value)], + style='font-family: monospace;') + + # assume the enum display is all we need, if enum exists for the field + if field in self.enums: + + # but skip the enum display if None + display = self.enums[field].get(value) + if display is None and value is None: + return text + + # otherwise show enum display to the right of raw value + display = self.enums[field].get(value, str(value)) + return HTML.tag('span', c=[ + text, + HTML.tag('span', c=[display], + style='margin-left: 2rem; font-style: italic; font-weight: bold;'), + ]) + + # next we look for a relationship and may render the foreign object + for prop in self.mapper.relationships: + if prop.uselist: + continue + + for col in prop.local_columns: + if col.name != field: + continue + + if not hasattr(version, prop.key): + continue + + if col in self.mapper.primary_key: + continue + + ref = getattr(version, prop.key) + if ref: + ref = getattr(ref, 'version_parent', None) + if ref: + return HTML.tag('span', c=[ + text, + HTML.tag('span', c=[str(ref)], + style='margin-left: 2rem; font-style: italic; font-weight: bold;'), + ]) + + return text + + def render_old_value(self, field): + if self.nature == 'new': + return '' + value = self.old_value(field) + return self.render_version_value(field, value, self.version.previous) + + def render_new_value(self, field): + if self.nature == 'deleted': + return '' + value = self.new_value(field) + return self.render_version_value(field, value, self.version) + + def as_struct(self): + values = {} + for field in self.fields: + values[field] = {'before': self.render_old_value(field), + 'after': self.render_new_value(field)} + + operation = None + if self.version.operation_type == continuum.Operation.INSERT: + operation = 'INSERT' + elif self.version.operation_type == continuum.Operation.UPDATE: + operation = 'UPDATE' + elif self.version.operation_type == continuum.Operation.DELETE: + operation = 'DELETE' + else: + operation = self.version.operation_type + + return { + 'key': id(self.version), + 'model_title': self.title, + 'operation': operation, + 'diff_class': self.nature, + 'fields': self.fields, + 'values': values, + } 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 14e5b1a3..34b34a6c 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.py @@ -1,36 +1,30 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ -Forms +Forms Library """ -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 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 9f8eede4..00000000 --- a/tailbone/forms/alchemy.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -FormAlchemy Forms -""" - -from __future__ import unicode_literals - -from rattail.core import Object -from pyramid.renderers import render -from ..db import Session - - -class AlchemyForm(Object): - """ - Form to contain a :class:`formalchemy.FieldSet` instance. - """ - - create_label = "Create" - update_label = "Save" - - allow_successive_creates = False - - def __init__(self, request, fieldset, **kwargs): - super(AlchemyForm, self).__init__(**kwargs) - self.request = request - self.fieldset = fieldset - - 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 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() - 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 4a2be3a7..4024557b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1,121 +1,1459 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 hashlib +import json +import logging +import warnings +from collections import OrderedDict -from edbob.util import prettify +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY +from wuttjamaican.util import UNSPECIFIED -from rattail.util import OrderedDict +from rattail.util import pretty_boolean +from rattail.db.util import get_fieldnames -import formalchemy -from formalchemy.helpers import content_tag +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 ebeda61f..00000000 --- a/tailbone/forms/fields.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -FormAlchemy Fields -""" - -from __future__ import unicode_literals, absolute_import - -from formalchemy import Field - - -def AssociationProxyField(name, **kwargs): - """ - Returns a FormAlchemy ``Field`` class which is aware of association - proxies. - """ - - class ProxyField(Field): - - def sync(self): - if not self.is_readonly(): - setattr(self.parent.model, self.name, - self.renderer.deserialize()) - - def value(model): - return getattr(model, name, None) - - kwargs.setdefault('value', value) - return ProxyField(name, **kwargs) 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 d64c5d88..00000000 --- a/tailbone/forms/renderers/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -FormAlchemy Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -from .core import CustomFieldRenderer, DateFieldRenderer - -from .common import (StrippedTextFieldRenderer, CodeTextAreaFieldRenderer, AutocompleteFieldRenderer, - DecimalFieldRenderer, CurrencyFieldRenderer, - DateTimeFieldRenderer, DateTimePrettyFieldRenderer, TimeFieldRenderer, - EnumFieldRenderer, YesNoFieldRenderer) - -from .files import FileFieldRenderer - -from .people import (PersonFieldRenderer, PersonFieldLinkRenderer, - CustomerFieldRenderer, CustomerFieldLinkRenderer) - -from .users import UserFieldRenderer, PermissionsFieldRenderer - -from .employees import EmployeeFieldRenderer - -from .products import (ProductFieldRenderer, GPCFieldRenderer, - BrandFieldRenderer, VendorFieldRenderer, - PriceFieldRenderer, PriceWithExpirationFieldRenderer) - -from .stores import StoreFieldRenderer - -from .vendors import VendorFieldRenderer - -from .batch import BatchIDFieldRenderer diff --git a/tailbone/forms/renderers/batch.py b/tailbone/forms/renderers/batch.py deleted file mode 100644 index 0c7034e0..00000000 --- a/tailbone/forms/renderers/batch.py +++ /dev/null @@ -1,82 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Batch Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -import os -import stat -import random - -import formalchemy as fa -from formalchemy.ext import fsblob -from formalchemy.fields import FileFieldRenderer as Base - - -class BatchIDFieldRenderer(fa.FieldRenderer): - """ - Renderer for batch ID fields. - """ - def render_readonly(self, **kwargs): - batch_id = self.raw_value - if batch_id: - return '{:08d}'.format(batch_id) - return '' - - -# TODO: make this inherit from `tailbone.forms.renderers.files.FileFieldRenderer` -class FileFieldRenderer(fsblob.FileFieldRenderer): - """ - Custom file field renderer for batches based on a single source data file. - In edit mode, shows a file upload field. In readonly mode, shows the - filename and its size. - """ - - @classmethod - def new(cls, view): - name = 'Configured%s_%s' % (cls.__name__, str(random.random())[2:]) - return type(str(name), (cls,), dict(view=view)) - - @property - def storage_path(self): - return self.view.upload_dir - - def get_size(self): - size = super(FileFieldRenderer, self).get_size() - if size: - return size - batch = self.field.parent.model - path = os.path.join(self.view.handler.datadir(batch), self.field.value) - if os.path.isfile(path): - return os.stat(path)[stat.ST_SIZE] - return 0 - - def get_url(self, filename): - batch = self.field.parent.model - return self.view.request.route_url('{}.download'.format(self.view.get_route_prefix()), - uuid=batch.uuid) - - def render(self, **kwargs): - return Base.render(self, **kwargs) diff --git a/tailbone/forms/renderers/bouncer.py b/tailbone/forms/renderers/bouncer.py deleted file mode 100644 index 75bb91db..00000000 --- a/tailbone/forms/renderers/bouncer.py +++ /dev/null @@ -1,63 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Batch Field Renderers -""" - -from __future__ import unicode_literals - -import os -import stat -import random - -from formalchemy.ext import fsblob - - -class BounceMessageFieldRenderer(fsblob.FileFieldRenderer): - """ - Custom file field renderer for email bounce messages. In readonly mode, - shows the filename and size. - """ - - @classmethod - def new(cls, request, handler): - name = 'Configured%s_%s' % (cls.__name__, unicode(random.random())[2:]) - return type(str(name), (cls,), dict(request=request, handler=handler)) - - @property - def storage_path(self): - return self.handler.root_msgdir - - def get_size(self): - size = super(BounceMessageFieldRenderer, self).get_size() - if size: - return size - bounce = self.field.parent.model - path = os.path.join(self.handler.msgpath(bounce)) - if os.path.isfile(path): - return os.stat(path)[stat.ST_SIZE] - return 0 - - def get_url(self, filename): - bounce = self.field.parent.model - return self.request.route_url('emailbounces.download', uuid=bounce.uuid) diff --git a/tailbone/forms/renderers/common.py b/tailbone/forms/renderers/common.py deleted file mode 100644 index 0ad9350f..00000000 --- a/tailbone/forms/renderers/common.py +++ /dev/null @@ -1,260 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Common Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -import datetime - -import pytz - -from rattail.time import localtime - -import formalchemy -from formalchemy import helpers -from formalchemy.fields import FieldRenderer, SelectFieldRenderer, CheckBoxFieldRenderer -from pyramid.renderers import render -from webhelpers.html import HTML - -from tailbone.util import pretty_datetime, raw_datetime - - -class StrippedTextFieldRenderer(formalchemy.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(formalchemy.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(FieldRenderer): - """ - Custom renderer for an autocomplete field. - """ - - service_route = None - width = '300px' - - @property - def focus_name(self): - return self.name + '-textbox' - - @property - def needs_focus(self): - return not bool(self.value or self.field_value) - - @property - def field_display(self): - return self.raw_value - - @property - def field_value(self): - return self.value - - @property - def service_url(self): - return self.request.route_url(self.service_route) - - def render(self, **kwargs): - kwargs.setdefault('field_name', self.name) - kwargs.setdefault('field_value', self.field_value) - kwargs.setdefault('field_display', self.field_display) - kwargs.setdefault('service_url', self.service_url) - kwargs.setdefault('width', self.width) - return render('/forms/field_autocomplete.mako', kwargs) - - def render_readonly(self, **kwargs): - value = self.field_display - if value is None: - return u'' - return unicode(value) - - -class DateTimeFieldRenderer(formalchemy.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(formalchemy.DateTimeFieldRenderer): - """ - Custom date/time field renderer, which displays a "pretty" value in - read-only mode, leveraging config to show the correct timezone. - """ - - def render_readonly(self, **kwargs): - value = self.raw_value - if not value: - return '' - return pretty_datetime(self.request.rattail_config, value) - - -class TimeFieldRenderer(formalchemy.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 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 = pytz.utc.localize(value) - return localtime(self.request.rattail_config, value).time() - return value - - def stringify_value(self, value, as_html=False): - if not as_html: - return self.render_value(value) - return super(TimeFieldRenderer, self).stringify_value(value, as_html=as_html) - - def _serialized_value(self): - return self.params.getone(self.name) - - def deserialize(self): - value = self._serialized_value() - if value: - try: - return datetime.datetime.strptime(value, self.format).time() - except ValueError: - pass - - -class EnumFieldRenderer(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 SelectFieldRenderer.render(self, opts, **kwargs) - - -class DecimalFieldRenderer(formalchemy.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(formalchemy.FieldRenderer): - """ - 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 YesNoFieldRenderer(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 e68d4777..00000000 --- a/tailbone/forms/renderers/core.py +++ /dev/null @@ -1,85 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Core Field Renderers -""" - -from __future__ import unicode_literals - -import datetime - -import formalchemy -from formalchemy.fields import AbstractField -from pyramid.renderers import render - - -class CustomFieldRenderer(formalchemy.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]) - 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, formalchemy.DateFieldRenderer): - """ - Date field renderer which uses jQuery UI datepicker widget when rendering - in edit mode. - """ - change_year = False - - def init(self, change_year=False): - self.change_year = change_year - - 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 - return datetime.datetime.strptime(value, '%Y-%m-%d') - - def _serialized_value(self): - return self.params.getone(self.name) diff --git a/tailbone/forms/renderers/employees.py b/tailbone/forms/renderers/employees.py deleted file mode 100644 index 0a9d7e55..00000000 --- a/tailbone/forms/renderers/employees.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Employee Field Renderers -""" - -from __future__ import unicode_literals - -from .common import AutocompleteFieldRenderer - - -class EmployeeFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Employee` instance fields. - """ - service_route = 'employees.autocomplete' - - def render_readonly(self, **kwargs): - employee = self.raw_value - if not employee: - return '' - return unicode(employee.person) diff --git a/tailbone/forms/renderers/files.py b/tailbone/forms/renderers/files.py deleted file mode 100644 index 940275af..00000000 --- a/tailbone/forms/renderers/files.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Batch Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -import os -import stat -import random - -from formalchemy.ext import fsblob -from formalchemy.fields import FileFieldRenderer as Base - - -class FileFieldRenderer(fsblob.FileFieldRenderer): - """ - Custom file field renderer. In readonly mode, shows a filename and its - size; in edit mode, supports a single file upload. - """ - - @classmethod - def new(cls, view, **kwargs): - name = b'Configured{}_{}'.format(cls.__name__, str(random.random())[2:]) - return type(name, (cls,), dict(view=view, **kwargs)) - - @property - def request(self): - return self.view.request - - @property - def storage_path(self): - return self.view.upload_dir - - def get_file_path(self): - """ - Returns the absolute path to the data file. - """ - if hasattr(self, 'file_path'): - return self.file_path - return self.field.value - - def get_size(self): - """ - Returns the size of the data file, in bytes. - """ - path = self.get_file_path() - if path and os.path.isfile(path): - return os.stat(path)[stat.ST_SIZE] - return 0 - - def get_url(self, filename): - url = self.get_download_url() - if url: - if callable(url): - return url(filename) - return url - - def get_download_url(self): - if hasattr(self, 'download_url'): - return self.download_url - - def render(self, **kwargs): - return Base.render(self, **kwargs) - - def render_readonly(self, **kwargs): - """ - Render the filename and the binary size in a human readable with a link - to the file itself. - """ - value = self.get_file_path() - if value: - content = '{} ({})'.format(fsblob.normalized_basename(value), - self.readable_size()) - return fsblob.h.content_tag('a', content, - href=self.get_url(value), **kwargs) - return '' diff --git a/tailbone/forms/renderers/people.py b/tailbone/forms/renderers/people.py deleted file mode 100644 index d1a1ee93..00000000 --- a/tailbone/forms/renderers/people.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -People Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -from .common import AutocompleteFieldRenderer -from webhelpers.html import tags - - -__all__ = ['PersonFieldRenderer', 'PersonFieldLinkRenderer', - 'CustomerFieldRenderer', 'CustomerFieldLinkRenderer'] - - -class PersonFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Person` instance fields. - """ - - service_route = 'people.autocomplete' - - -class PersonFieldLinkRenderer(PersonFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Person` instance fields (with hyperlink). - """ - - def render_readonly(self, **kwargs): - person = self.raw_value - if person: - return tags.link_to( - unicode(person), - self.request.route_url('people.view', uuid=person.uuid)) - return u'' - - -class CustomerFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Customer` instance fields. - """ - - service_route = 'customers.autocomplete' - - -class CustomerFieldLinkRenderer(CustomerFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Customer` instance fields (with hyperlink). - """ - - def render_readonly(self, **kwargs): - customer = self.raw_value - if customer: - return tags.link_to( - unicode(customer), - self.request.route_url('customers.view', uuid=customer.uuid)) - return u'' diff --git a/tailbone/forms/renderers/products.py b/tailbone/forms/renderers/products.py deleted file mode 100644 index 5ae60954..00000000 --- a/tailbone/forms/renderers/products.py +++ /dev/null @@ -1,166 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Product Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -from rattail.gpc import GPC - -from formalchemy import TextFieldRenderer -from webhelpers.html import tags, literal - -from tailbone.forms.renderers.common import AutocompleteFieldRenderer -from tailbone.util import pretty_datetime - - -class ProductFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Product` instance fields. - """ - - service_route = 'products.autocomplete' - - @property - def field_display(self): - product = self.raw_value - if product: - return product.full_description - return '' - - -class GPCFieldRenderer(TextFieldRenderer): - """ - 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_readonly(self, **kwargs): - gpc = self.raw_value - if gpc is None: - return '' - gpc = unicode(gpc) - gpc = '{}-{}'.format(gpc[:-1], gpc[-1]) - if kwargs.get('link'): - product = self.field.parent.model - gpc = tags.link_to(gpc, kwargs['link'](product)) - return gpc - - -class DepartmentFieldRenderer(TextFieldRenderer): - """ - Shows the department number as well as the name. - """ - def render_readonly(self, **kwargs): - dept = self.raw_value - if dept: - return "{0} - {1}".format(dept.number, dept.name) - return "" - - -class SubdepartmentFieldRenderer(TextFieldRenderer): - """ - Shows the subdepartment number as well as the name. - """ - def render_readonly(self, **kwargs): - sub = self.raw_value - if sub: - return "{0} - {1}".format(sub.number, sub.name) - return "" - - -class CategoryFieldRenderer(TextFieldRenderer): - """ - Shows the category number as well as the name. - """ - def render_readonly(self, **kwargs): - cat = self.raw_value - if cat: - return "{0} - {1}".format(cat.number, cat.name) - return "" - - -class BrandFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Brand` instance fields. - """ - - service_route = 'brands.autocomplete' - - -class VendorFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Vendor` instance fields. - """ - service_route = 'vendors.autocomplete' - - def render_readonly(self, **kwargs): - vendor = self.raw_value - if not vendor: - return '' - return "{0} - {1}".format(vendor.id, vendor.name) - - -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/stores.py b/tailbone/forms/renderers/stores.py deleted file mode 100644 index bbe1545c..00000000 --- a/tailbone/forms/renderers/stores.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Store Field Renderers -""" - -from __future__ import unicode_literals - -from formalchemy.fields import SelectFieldRenderer - - -class StoreFieldRenderer(SelectFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Store` instance fields. - """ - - def render_readonly(self, **kwargs): - store = self.raw_value - if not store: - return '' - return '{0} - {1}'.format(store.id, store.name) diff --git a/tailbone/forms/renderers/users.py b/tailbone/forms/renderers/users.py deleted file mode 100644 index a3a92ffa..00000000 --- a/tailbone/forms/renderers/users.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -User Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -from rattail.db import model -from rattail.db.auth import has_permission, administrator_role - -import formalchemy -from webhelpers.html import HTML, tags - -from tailbone.db import Session - - -class UserFieldRenderer(formalchemy.TextFieldRenderer): - """ - Renderer for :class:`rattail:rattail.db.model.User` instance fields. - """ - - def render_readonly(self, **kwargs): - user = self.raw_value - if not user: - return '' - return user.display_name - - -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 - if isinstance(principal, model.Role) and principal is administrator_role(Session()): - html = HTML.tag('p', c="This is the administrative role; " - "it has full access to the entire system.") - if not readonly: - html += tags.hidden(self.name, value='') # ugly hack..or good idea? - else: - 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) - if not html: - return "(none granted)" - return html - - 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 0d1916b9..00000000 --- a/tailbone/forms/renderers/vendors.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Vendor Field Renderers -""" - -from __future__ import unicode_literals, absolute_import - -from .common import AutocompleteFieldRenderer - - -class VendorFieldRenderer(AutocompleteFieldRenderer): - """ - Renderer for :class:`rattail.db.model.Vendor` instance fields. - """ - service_route = 'vendors.autocomplete' diff --git a/tailbone/forms/simpleform.py b/tailbone/forms/simpleform.py deleted file mode 100644 index b1510148..00000000 --- a/tailbone/forms/simpleform.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Simple Forms -""" - -from __future__ import unicode_literals, absolute_import - -import pyramid_simpleform -from pyramid_simpleform import renderers - -from webhelpers.html import tags -from webhelpers.html import HTML - -from edbob.util import prettify - -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) - - -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 e99bc37f..00000000 --- a/tailbone/forms/validators.py +++ /dev/null @@ -1,104 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Custom FormEncode Validators -""" - -from __future__ import unicode_literals, absolute_import - -from rattail.db import model - -import formencode as fe - -from tailbone.db import Session - - -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 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/grids/__init__.py b/tailbone/grids/__init__.py index dc9032b8..7db22b26 100644 --- a/tailbone/grids/__init__.py +++ b/tailbone/grids/__init__.py @@ -1,32 +1,30 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ - """ -Grids +Grids and Friends """ -from .core import * -from .alchemy import * -from . import util -from . import search +from __future__ import unicode_literals, absolute_import + +from . import filters +from .core import Grid, GridAction diff --git a/tailbone/grids/alchemy.py b/tailbone/grids/alchemy.py deleted file mode 100644 index 7b32069a..00000000 --- a/tailbone/grids/alchemy.py +++ /dev/null @@ -1,126 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -FormAlchemy Grid Classes -""" - -from __future__ import unicode_literals - -from sqlalchemy.orm import object_session - -try: - from sqlalchemy.inspection import inspect -except ImportError: - inspect = None - from sqlalchemy.orm import class_mapper - -from webhelpers.html import tags -from webhelpers.html import HTML - -import formalchemy - -from edbob.util import prettify - -from .core import Grid -from ..db import Session - -__all__ = ['AlchemyGrid'] - - -class AlchemyGrid(Grid): - - sort_map = {} - - pager = None - pager_format = '$link_first $link_previous ~1~ $link_next $link_last' - - def __init__(self, request, cls, instances, **kwargs): - super(AlchemyGrid, self).__init__(request, **kwargs) - self._formalchemy_grid = formalchemy.Grid( - cls, instances, session=Session(), request=request) - self._formalchemy_grid.prettify = prettify - - def __delattr__(self, attr): - delattr(self._formalchemy_grid, attr) - - def __getattr__(self, attr): - return getattr(self._formalchemy_grid, attr) - - def cell_class(self, field): - classes = [field.name] - return ' '.join(classes) - - def checkbox(self, row): - return tags.checkbox('check-'+row.uuid) - - def column_header(self, field): - class_ = None - label = field.label() - if field.key in self.sort_map: - class_ = 'sortable' - if field.key == self.config['sort']: - class_ += ' sorted ' + self.config['dir'] - label = tags.link_to(label, '#') - return HTML.tag('th', class_=class_, field=field.key, - title=self.column_titles.get(field.key), c=label) - - def crud_route_kwargs(self, row): - if inspect: - mapper = inspect(row.__class__) - else: - mapper = class_mapper(row.__class__) - keys = [k.key for k in mapper.primary_key] - values = [getattr(row, k) for k in keys] - return dict(zip(keys, values)) - - view_route_kwargs = crud_route_kwargs - edit_route_kwargs = crud_route_kwargs - delete_route_kwargs = crud_route_kwargs - - def iter_fields(self): - return self._formalchemy_grid.render_fields.itervalues() - - def iter_rows(self): - for row in self._formalchemy_grid.rows: - self._formalchemy_grid._set_active(row, object_session(row)) - yield row - - def page_count_options(self): - return [5, 10, 20, 50, 100] - - def page_links(self): - return self.pager.pager(self.pager_format, - symbol_next='next', - symbol_previous='prev', - onclick="grid_navigate_page(this, '$partial_url'); return false;") - - def render_field(self, field): - if self._formalchemy_grid.readonly: - return field.render_readonly() - return field.render() - - def row_attrs(self, row, i): - attrs = super(AlchemyGrid, self).row_attrs(row, i) - if hasattr(row, 'uuid'): - attrs['uuid'] = row.uuid - return attrs diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 911441ae..56b97b86 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1,155 +1,1623 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ - """ Core Grid Classes """ -try: - from collections import OrderedDict -except ImportError: - from ordereddict import OrderedDict +import inspect +import logging +import warnings +from urllib.parse import urlencode -from webhelpers.html import HTML -from webhelpers.html.builder import format_attrs +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.util import UNSPECIFIED +from rattail.db.types import GPCType +from rattail.util import prettify, pretty_boolean from pyramid.renderers import render +from webhelpers2.html import HTML, tags +from paginate_sqlalchemy import SqlalchemyOrmPage -from rattail.core import Object +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 -__all__ = ['Grid'] +log = logging.getLogger(__name__) -class Grid(Object): +class Grid(WuttaGrid): + """ + Base class for all grids. - full = False - hoverable = True - checkboxes = False - partial_only = False + This is now a subclass of + :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add + customizations which have traditionally been part of Tailbone. - viewable = False - view_route_name = None - view_route_kwargs = None + 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. - editable = False - edit_route_name = None - edit_route_kwargs = None + .. _Buefy docs: https://buefy.org/documentation/table/ - deletable = False - delete_route_name = None - delete_route_kwargs = None + .. attribute:: checkable - # Set this to a callable to allow ad-hoc row class additions. - extra_row_class = None + Optional callback to determine if a given row is checkable, + i.e. this allows hiding checkbox for certain rows if needed. - def __init__(self, request, **kwargs): - kwargs.setdefault('fields', OrderedDict()) - kwargs.setdefault('column_titles', {}) - kwargs.setdefault('extra_columns', []) - super(Grid, self).__init__(**kwargs) + 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, + 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.width = width + self.enums = enums 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.url = url + + self.checkboxes = checkboxes + self.checked = checked + if self.checked is None: + self.checked = lambda item: False + self.check_handler = check_handler + self.check_all_handler = check_all_handler + self.checkable = checkable + self.row_uuid_getter = row_uuid_getter + self.clicking_row_checks_box = clicking_row_checks_box + + self.click_handlers = click_handlers or {} + + self.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 + + @property + def component(self): + """ """ + warnings.warn("Grid.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + + @property + def component_studly(self): + """ """ + 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): + """ + 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: + warnings.warn("specifying None is deprecated for Grid.set_joiner(); " + "please use Grid.remove_joiner() instead", + DeprecationWarning, stacklevel=2) + self.remove_joiner(key) + else: + super().set_joiner(key, joiner) + + def set_sorter(self, key, *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_click_handler(self, key, handler): + if handler: + self.click_handlers[key] = handler + else: + self.click_handlers.pop(key, None) + + def has_click_handler(self, key): + return key in self.click_handlers + + 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': + self.set_renderer(key, self.render_boolean) + elif type_ == 'currency': + self.set_renderer(key, self.render_currency) + elif type_ == 'datetime': + self.set_renderer(key, self.render_datetime) + elif type_ == 'datetime_local': + self.set_renderer(key, self.render_datetime_local) + elif type_ == 'enum': + self.set_renderer(key, self.render_enum) + elif type_ == 'gpc': + self.set_renderer(key, self.render_gpc) + elif type_ == 'percent': + self.set_renderer(key, self.render_percent) + elif type_ == 'quantity': + 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_)) + + def set_enum(self, key, enum): + 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) + + def render_generic(self, obj, column_name): + return self.obtain_value(obj, column_name) + + def render_boolean(self, obj, column_name): + value = self.obtain_value(obj, column_name) + 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: + pass + + def render_currency(self, obj, column_name): + value = self.obtain_value(obj, column_name) + if value is None: + return "" + if value < 0: + return "(${:0,.2f})".format(0 - value) + return "${:0,.2f}".format(value) + + def render_datetime(self, obj, column_name): + value = self.obtain_value(obj, column_name) + if value is None: + return "" + return raw_datetime(self.request.rattail_config, value) + + def render_datetime_local(self, obj, column_name): + value = self.obtain_value(obj, column_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_enum(self, obj, column_name): + value = self.obtain_value(obj, column_name) + if value is None: + return "" + enum = self.enums.get(column_name) + if enum and value in enum: + return str(enum[value]) + return str(value) + + def render_gpc(self, obj, column_name): + value = self.obtain_value(obj, column_name) + if value is None: + return "" + return value.pretty() + + def render_percent(self, obj, column_name): + app = self.request.rattail_config.get_app() + value = self.obtain_value(obj, column_name) + return app.render_percent(value, places=3) + + def render_quantity(self, obj, column_name): + value = self.obtain_value(obj, column_name) + app = self.request.rattail_config.get_app() + return app.render_quantity(value) + + def render_duration(self, obj, column_name): + seconds = self.obtain_value(obj, column_name) + 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 "" + app = self.request.rattail_config.get_app() + return app.render_duration(hours=value) + + def set_url(self, url): + self.url = url + + def make_url(self, obj, i=None): + if callable(self.url): + return self.url(obj) + return self.url + + def make_default_renderers(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: + if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'): + if prop.key in self.columns and prop.key not in renderers: + if len(prop.columns) == 1: + coltype = prop.columns[0].type + renderers[prop.key] = self.get_renderer_for_column_type(coltype) + + return renderers + + def get_renderer_for_column_type(self, coltype): + """ + Returns an appropriate renderer according to the given SA column type. + """ + if isinstance(coltype, sa.Boolean): + return self.render_boolean + + if isinstance(coltype, sa.DateTime): + if self.assume_local_times: + return self.render_datetime_local + else: + return self.render_datetime + + if isinstance(coltype, GPCType): + return self.render_gpc + + return self.render_generic + + def checkbox_column_format(self, column_number, row_number, item): + return HTML.td(self.render_checkbox(item), class_='checkbox') + + def actions_column_format(self, column_number, row_number, item): + return HTML.td(self.render_actions(item, row_number), class_='actions') + + # 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): + """ + Returns the default set of filters provided by the grid. + """ + if hasattr(self, 'default_filters'): + if callable(self.default_filters): + return self.default_filters() + return self.default_filters + filters = gridfilters.GridFilterSet() + if self.model_class: + mapper = orm.class_mapper(self.model_class) + for prop in mapper.iterate_properties: + 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_filter(self, key, column, **kwargs): + """ + Make a filter suitable for use with the given column. + """ + factory = kwargs.pop('factory', None) + if not factory: + factory = gridfilters.AlchemyGridFilter + if isinstance(column.type, sa.String): + factory = gridfilters.AlchemyStringFilter + elif isinstance(column.type, sa.Numeric): + factory = gridfilters.AlchemyNumericFilter + elif isinstance(column.type, sa.BigInteger): + factory = gridfilters.AlchemyBigIntegerFilter + elif isinstance(column.type, sa.Integer): + factory = gridfilters.AlchemyIntegerFilter + elif isinstance(column.type, sa.Boolean): + # TODO: check column for nullable here? + factory = gridfilters.AlchemyNullableBooleanFilter + elif isinstance(column.type, sa.Date): + factory = gridfilters.AlchemyDateFilter + elif isinstance(column.type, sa.DateTime): + if self.assume_local_times: + factory = gridfilters.AlchemyLocalDateTimeFilter + else: + factory = gridfilters.AlchemyDateTimeFilter + elif isinstance(column.type, GPCType): + factory = gridfilters.AlchemyGPCFilter + kwargs['column'] = column + kwargs.setdefault('config', self.request.rattail_config) + kwargs.setdefault('encode_values', self.use_byte_string_filters) + return factory(key, **kwargs) + + def iter_filters(self): + """ + Iterate over all filters available to the grid. + """ + return self.filters.values() + + def iter_active_filters(self): + """ + Iterate over all *active* filters for the grid. Whether a filter is + active is determined by current grid settings. + """ + for filtr in self.iter_filters(): + if filtr.active: + yield filtr + + def make_simple_sorter(self, key, foldcase=False): + """ """ + warnings.warn("Grid.make_simple_sorter() is deprecated; " + "please use Grid.make_sorter() instead", + DeprecationWarning, stacklevel=2) + return self.make_sorter(key, foldcase=foldcase) + + def get_pagesize_options(self, default=None): + """ """ + # let upstream check config + options = super().get_pagesize_options(default=UNSPECIFIED) + if options is not UNSPECIFIED: + return options + + # fallback to legacy config + options = self.config.get_list('tailbone.grid.pagesize_options') + if options: + warnings.warn("tailbone.grid.pagesize_options setting is deprecated; " + "please set wuttaweb.grids.default_pagesize_options instead", + DeprecationWarning) + options = [int(size) for size in options + if size.isdigit()] + if options: + return options + + if default: + return default + + # use upstream default + return super().get_pagesize_options() + + def get_pagesize(self, default=None): + """ """ + # let upstream check config + pagesize = super().get_pagesize(default=UNSPECIFIED) + if pagesize is not UNSPECIFIED: + return pagesize + + # fallback to legacy config + pagesize = self.config.get_int('tailbone.grid.default_pagesize') + if pagesize: + warnings.warn("tailbone.grid.default_pagesize setting is deprecated; " + "please use wuttaweb.grids.default_pagesize instead", + DeprecationWarning) + return pagesize + + if default: + return default + + # use upstream default + return super().get_pagesize() + + def get_default_pagesize(self): # pragma: no cover + """ """ + warnings.warn("Grid.get_default_pagesize() method is deprecated; " + "please use Grid.get_pagesize() of Grid.page instead", + DeprecationWarning, stacklevel=2) + + 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: + 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(): + 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(): + self.apply_user_defaults(settings) + + # 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-view'): + pass + + # If request has filter settings, grab those, then grab sort/pager + # settings from request or session. + elif self.request_has_settings('filter'): + self.update_filter_settings(settings, src='request') + if self.request_has_settings('sort'): + self.update_sort_settings(settings, src='request') + else: + 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, src='request') + self.update_filter_settings(settings, src='session') + self.update_page_settings(settings) + + # NOTE: These next two are functionally equivalent, but are kept + # separate to maintain the narrative... + + # If request has no filter/sort settings but does have pager settings, + # 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, 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, 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: + persist = False + + # Maybe store settings for next time. + 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, dest='defaults') + + # update ourself to reflect settings + if self.filterable: + for filtr in self.iter_filters(): + filtr.active = settings['filter.{}.active'.format(filtr.key)] + filtr.verb = settings['filter.{}.verb'.format(filtr.key)] + filtr.value = settings['filter.{}.value'.format(filtr.key)] + if self.sortable: + # 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'] + + def user_has_defaults(self): + """ + Check to see if the current user has default settings on file for this grid. + """ + user = self.request.user + if not user: + return False + + # NOTE: we used to leverage `self.session` here, but sometimes we might + # be showing a grid of data from another system...so always use + # Tailbone Session now, for the settings. hopefully that didn't break + # anything... + session = Session() + if user not in session: + # 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) + + 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): + value = app.get_setting(session, f'{prefix}.{key}') + settings[key] = normalize(value) + + if self.filterable: + for filtr in self.iter_filters(): + merge('filter.{}.active'.format(filtr.key), lambda v: v == 'true') + merge('filter.{}.verb'.format(filtr.key)) + merge('filter.{}.value'.format(filtr.key)) + + if self.sortable: + + # 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_): + """ """ + if super().request_has_settings(type_): + return True + + 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 + + return False + + def session_has_settings(self): + """ + Determine if the current session contains any settings for the grid. + """ + # session should have all or nothing, so just check a few keys which + # should be guaranteed present if anything has been stashed + 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(f'{prefix}.filter') + for key in self.request.session]) + + def persist_settings(self, settings, dest='session'): + """ """ + if dest not in ('defaults', 'session'): + raise ValueError(f"invalid dest identifier: {dest}") + + app = self.request.rattail_config.get_app() + model = app.model + + def persist(key, value=lambda k: settings.get(k)): + if dest == 'defaults': + skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) + 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: str(settings[k]).lower()) + persist('filter.{}.verb'.format(filtr.key)) + persist('filter.{}.value'.format(filtr.key)) + + if self.sortable: + + # 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') + + def filter_data(self, data): + """ + Filter and return the given data set, according to current settings. + """ + for filtr in self.iter_active_filters(): + + # apply filter to data but save reference to original; if data is a + # SQLAlchemy query and wasn't modified, we don't need to bother + # with the underlying join (if there is one) + original = data + data = filtr.filter(data) + if filtr.key in self.joiners and filtr.key not in self.joined and ( + not isinstance(data, orm.Query) or data is not original): + + # this filter requires a join; apply that + data = self.joiners[filtr.key](data) + self.joined.add(filtr.key) + + return data + + def make_visible_data(self): + """ """ + 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 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) + context.setdefault('view_click_handler', self.get_view_click_handler()) + html = render(template, context) + return HTML.literal(html) + + def render_buefy(self, **kwargs): + """ """ + warnings.warn("Grid.render_buefy() is deprecated; " + "please use Grid.render_complete() instead", + DeprecationWarning, stacklevel=2) + return self.render_complete(**kwargs) + + def render_table_element(self, template='/grids/b-table.mako', + data_prop='gridData', empty_labels=False, + literal=False, + **kwargs): + """ + This is intended for ad-hoc "small" grids with static data. Renders + just a ``<b-table>`` element instead of the typical "full" grid. + """ + context = dict(kwargs) + context['grid'] = self + context['request'] = self.request + context['data_prop'] = data_prop + context['empty_labels'] = empty_labels + if 'grid_columns' not in context: + context['grid_columns'] = self.get_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. + """ + data = {} + for filtr in self.filters.values(): + + valueless = [v for v in filtr.valueless_verbs + if v in filtr.verbs] + + multiple_values = [v for v in filtr.multiple_value_verbs + if v in filtr.verbs] + + 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) + + url = action.get_url(row, i) + if url: + kwargs = {'class_': action.key, 'target': action.target} + if action.icon: + icon = HTML.tag('span', class_='ui-icon ui-icon-{}'.format(action.icon)) + return tags.link_to(icon + action.label, url, **kwargs) + return tags.link_to(action.label, url, **kwargs) + + def get_row_key(self, item): + """ + Must return a unique key for the given data item's row. + """ + mapper = orm.object_mapper(item) + if len(mapper.primary_key) == 1: + return getattr(item, mapper.primary_key[0].key) + raise NotImplementedError + + def checkbox(self, item): + """ + Returns boolean indicating whether a checkbox should be rendererd for + the given data item's row. + """ + return True + + def render_checkbox(self, item): + """ + Renders a checkbox cell for the given item, if applicable. + """ + if not self.checkbox(item): + return '' + return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)), + checked=self.checked(item)) + + 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() + + for column in columns: + column['visible'] = column['field'] not in self.invisible + + return columns + + def get_table_columns(self): + """ """ + warnings.warn("grid.get_table_columns() method is deprecated; " + "please use grid.get_vue_columns() instead", + DeprecationWarning, stacklevel=2) + return self.get_vue_columns() + + def get_uuid_for_row(self, rowobj): + + # use custom getter if set + if self.row_uuid_getter: + return self.row_uuid_getter(rowobj) + + # otherwise fallback to normal uuid, if present + if hasattr(rowobj, 'uuid'): + return rowobj.uuid + + def get_vue_context(self): + """ """ + return self.get_table_data() + + def get_vue_data(self): + """ """ + table_data = self.get_table_data() + return table_data['data'] + + def get_table_data(self): + """ + Returns a list of data rows for the grid, for use with + 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: + count = len(raw_data) + + # iterate over data rows + checkable = self.checkboxes and self.checkable and callable(self.checkable) + for i in range(count): + rowobj = raw_data[i] + + # 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 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: + results['total_items'] = count + + self._table_data = results + return self._table_data + + # TODO: remove this when we use upstream GridAction + def add_action(self, key, **kwargs): + """ """ + self.actions.append(GridAction(self.request, key, **kwargs)) + + def set_action_urls(self, row, rowobj, i): + """ + Pre-generate all action URLs for the given data row. Meant for use + with 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(WuttaGridAction): + """ + 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, + 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 + self.click_handler = click_handler + + +class URLMaker(object): + """ + URL constructor for use with SQLAlchemy grid pagers. Logic for this was + basically copied from the old `webhelpers.paginate` module + """ + + def __init__(self, request): self.request = request - def add_column(self, name, label, callback): - self.extra_columns.append( - Object(name=name, label=label, callback=callback)) - - def column_header(self, field): - return HTML.tag('th', field=field.name, - title=self.column_titles.get(field.name), - c=field.label) - - def div_attrs(self): - classes = ['grid'] - if self.full: - classes.append('full') - if self.hoverable: - classes.append('hoverable') - return format_attrs( - class_=' '.join(classes), - url=self.request.current_route_url(_query=None)) - - def get_view_url(self, row): - kwargs = {} - if self.view_route_kwargs: - if callable(self.view_route_kwargs): - kwargs = self.view_route_kwargs(row) - else: - kwargs = self.view_route_kwargs - return self.request.route_url(self.view_route_name, **kwargs) - - def get_edit_url(self, row): - kwargs = {} - if self.edit_route_kwargs: - if callable(self.edit_route_kwargs): - kwargs = self.edit_route_kwargs(row) - else: - kwargs = self.edit_route_kwargs - return self.request.route_url(self.edit_route_name, **kwargs) - - def get_delete_url(self, row): - kwargs = {} - if self.delete_route_kwargs: - if callable(self.delete_route_kwargs): - kwargs = self.delete_route_kwargs(row) - else: - kwargs = self.delete_route_kwargs - return self.request.route_url(self.delete_route_name, **kwargs) - - def get_row_attrs(self, row, i): - attrs = self.row_attrs(row, i) - return format_attrs(**attrs) - - def row_attrs(self, row, i): - return {'class_': self.get_row_class(row, i)} - - def get_row_class(self, row, i): - class_ = self.default_row_class(row, i) - if callable(self.extra_row_class): - extra = self.extra_row_class(row, i) - if extra: - class_ = '{0} {1}'.format(class_, extra) - return class_ - - def default_row_class(self, row, i): - return 'odd' if i % 2 else 'even' - - def iter_fields(self): - return self.fields.itervalues() - - def iter_rows(self): - """ - Iterate over the grid rows. The default implementation simply returns - an iterator over ``self.rows``; note however that by default there is - no such attribute. You must either populate that, or overrirde this - method. - """ - return iter(self.rows) - - def render(self, template='/grids/grid.mako', **kwargs): - kwargs.setdefault('grid', self) - return render(template, kwargs) - - def render_field(self, field): - raise NotImplementedError + def __call__(self, page): + params = self.request.GET.copy() + params["page"] = page + params["partial"] = "1" + qs = urlencode(params, True) + return '{}?{}'.format(self.request.path, qs) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py new file mode 100644 index 00000000..7e52bb8d --- /dev/null +++ b/tailbone/grids/filters.py @@ -0,0 +1,1301 @@ +# -*- 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/>. +# +################################################################################ +""" +Grid Filters +""" + +import re +import datetime +import decimal +import logging +from collections import OrderedDict + +import sqlalchemy as sa + +from rattail.gpc import GPC +from rattail.core import UNSPECIFIED +from rattail.time import localtime, make_utc +from rattail.util import prettify + +import colander +from webhelpers2.html import HTML, tags + +from tailbone import forms + + +log = logging.getLogger(__name__) + + +class FilterValueRenderer(object): + """ + Base class for all filter renderers. + """ + data_type = 'string' + + @property + def name(self): + return self.filter.key + + def render(self, value=None, **kwargs): + """ + Render the filter input element(s) as HTML. Default implementation + uses a simple text input. + """ + return tags.text(self.name, value=value, **kwargs) + + +class DefaultValueRenderer(FilterValueRenderer): + """ + Default / fallback renderer. + """ + + +class NumericValueRenderer(FilterValueRenderer): + """ + Input renderer for numeric values. + """ + data_type = 'number' + + def render(self, value=None, **kwargs): + kwargs.setdefault('step', '0.001') + return tags.text(self.name, value=value, type='number', **kwargs) + + +class DateValueRenderer(FilterValueRenderer): + """ + Input renderer for date values. + """ + data_type = 'date' + + def render(self, value=None, **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 + + def render(self, value=None, **kwargs): + return tags.select(self.name, [value], self.options, **kwargs) + + +class EnumValueRenderer(ChoiceValueRenderer): + """ + Renders value input as a dropdown/selectmenu of available choices. + """ + + def __init__(self, enum): + 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): + """ + Represents a filter available to a grid. This is used to construct the + 'filters' section when rendering the index page template. + """ + verb_labels = { + '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_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, 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.default_active = default_active + self.default_verb = default_verb + self.default_value = default_value + + self.encode_values = encode_values + self.value_encoding = value_encoding + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __repr__(self): + return "{}({})".format(self.__class__.__name__, repr(self.key)) + + def get_default_verbs(self): + """ + Returns the set of verbs which will be used by default, i.e. unless + overridden by constructor args etc. + """ + verbs = getattr(self, 'default_verbs', None) + if verbs: + if callable(verbs): + return verbs() + 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. + """ + if not isinstance(renderer, FilterValueRenderer): + renderer = renderer() + renderer.filter = self + self.value_renderer = renderer + self.data_type = renderer.data_type + + def filter(self, data, verb=None, value=UNSPECIFIED): + """ + Filter the given data set according to a verb/value pair. If no verb + and/or value is specified by the caller, the filter will use its own + current verb/value by default. + """ + verb = verb or self.verb + value = self.get_value(value) + filtr = getattr(self, 'filter_{0}'.format(verb), None) + if not filtr: + log.warning("unknown filter verb: %s", verb) + return data + 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 + cases to add an "ineffective" option to the verb list for a given grid + filter. + """ + return data + + def render_value(self, value=UNSPECIFIED, **kwargs): + """ + Render the HTML needed to expose the filter's value for user input. + """ + if value is UNSPECIFIED: + value = self.value + kwargs['filtr'] = self + return self.value_renderer.render(value=value, **kwargs) + + +class AlchemyGridFilter(GridFilter): + """ + Base class for SQLAlchemy grid filters. + """ + + def __init__(self, *args, **kwargs): + self.column = kwargs.pop('column') + super().__init__(*args, **kwargs) + + def filter_equal(self, query, value): + """ + Filter data with an equal ('=') query. + """ + if value is None or value == '': + return query + return query.filter(self.column == self.encode_value(value)) + + def filter_not_equal(self, query, value): + """ + Filter data with a not eqaul ('!=') query. + """ + if value is None or value == '': + return query + + # When saying something is 'not equal' to something else, we must also + # include things which are nothing at all, in our result set. + return query.filter(sa.or_( + self.column == None, + 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 + use the value for anything. + """ + return query.filter(self.column == None) + + def filter_is_not_null(self, query, value): + """ + Filter data with an 'IS NOT NULL' query. Note that this filter does + not use the value for anything. + """ + return query.filter(self.column != None) + + def filter_greater_than(self, query, value): + """ + Filter data with a greater than ('>') query. + """ + if value is None or value == '': + return query + return query.filter(self.column > self.encode_value(value)) + + def filter_greater_equal(self, query, value): + """ + Filter data with a greater than or equal ('>=') query. + """ + if value is None or value == '': + return query + return query.filter(self.column >= self.encode_value(value)) + + def filter_less_than(self, query, value): + """ + Filter data with a less than ('<') query. + """ + if value is None or value == '': + return query + return query.filter(self.column < self.encode_value(value)) + + def filter_less_equal(self, query, value): + """ + Filter data with a less than or equal ('<=') query. + """ + if value is None or value == '': + return query + return query.filter(self.column <= self.encode_value(value)) + + def filter_between(self, query, value): + """ + Filter data with a "between" query. Really this uses ">=" and + "<=" (inclusive) logic instead of SQL "between" keyword. + """ + if value is None or value == '': + return query + + if '|' not in value: + return query + + values = value.split('|') + if len(values) != 2: + return query + + start_value, end_value = values + + # we'll only filter if we have start and/or end value + if not start_value and not end_value: + return query + + return self.filter_for_range(query, start_value, end_value) + + def filter_for_range(self, query, start_value, end_value): + """ + This method should actually apply filter(s) to the query, + according to the given value range. Subclasses may override + this logic. + """ + if start_value: + if self.value_invalid(start_value): + return query + query = query.filter(self.column >= self.encode_value(start_value)) + + if end_value: + if self.value_invalid(end_value): + return query + query = query.filter(self.column <= self.encode_value(end_value)) + + return query + + +class AlchemyStringFilter(AlchemyGridFilter): + """ + String filter for SQLAlchemy. + """ + + def default_verbs(self): + """ + Expose contains / does-not-contain verbs in addition to core. + """ + + if self.choices: + return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'] + + return ['contains', 'does_not_contain', + 'contains_any_of', + 'equal', 'not_equal', 'equal_any_of', + 'is_empty', 'is_not_empty', + 'is_null', 'is_not_null', + 'is_empty_or_null', + 'is_any'] + + def filter_contains(self, query, value): + """ + Filter data with a full 'ILIKE' query. + """ + if value is None or value == '': + return query + + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(self.column.ilike(val)) + return query.filter(sa.and_(*criteria)) + + def filter_does_not_contain(self, query, value): + """ + Filter data with a full 'NOT ILIKE' query. + """ + if value is None or value == '': + return query + + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = self.encode_value(f'%{val}%') + criteria.append(~self.column.ilike(val)) + + # When saying something is 'not like' something else, we must also + # include things which are nothing at all, in our result set. + return query.filter(sa.or_( + self.column == None, + sa.and_(*criteria))) + + 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): + """ + String filter with special logic to treat empty string values as NULL + """ + + def filter_is_null(self, query, value): + return query.filter( + sa.or_( + self.column == None, + 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.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))) + + +class AlchemyByteStringFilter(AlchemyStringFilter): + """ + String filter for SQLAlchemy, which encodes value as bytestring before + passing it along to the query. Useful when querying certain databases + (esp. via FreeTDS) which like to throw the occasional segfault... + """ + value_encoding = 'utf-8' + + def get_value(self, value=UNSPECIFIED): + value = super().get_value(value) + if isinstance(value, str): + value = value.encode(self.value_encoding) + return value + + def filter_contains(self, query, value): + """ + Filter data with a full 'ILIKE' query. + """ + if value is None or value == '': + return query + + criteria = [] + for val in value.split(): + val = val.replace('_', r'\_') + val = b'%{}%'.format(val) + criteria.append(self.column.ilike(val)) + return query.filters(sa.and_(*criteria)) + + def filter_does_not_contain(self, query, value): + """ + Filter data with a full 'NOT ILIKE' query. + """ + if value is None or value == '': + return query + + for val in value.split(): + val = val.replace('_', '\_') + val = b'%{}%'.format(val) + criteria.append(~self.column.ilike(val)) + + # When saying something is 'not like' something else, we must also + # include things which are nothing at all, in our result set. + return query.filter(sa.or_( + self.column == None, + sa.and_(*criteria))) + + +class AlchemyNumericFilter(AlchemyGridFilter): + """ + Numeric filter for SQLAlchemy. + """ + value_renderer_factory = NumericValueRenderer + + def default_verbs(self): + # expose greater-than / less-than verbs in addition to core + return ['equal', 'not_equal', 'greater_than', 'greater_equal', + 'less_than', 'less_equal', 'between', + 'is_null', 'is_not_null', 'is_any'] + + # 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 + + # try to detect (and ignore) common mistake where user enters UPC as search + # term for integer field... + + def value_invalid(self, value): + + # 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().filter_equal(query, value) + + def filter_not_equal(self, query, value): + if self.value_invalid(value): + return query + return super().filter_not_equal(query, value) + + def filter_greater_than(self, query, value): + if self.value_invalid(value): + return query + return super().filter_greater_than(query, value) + + def filter_greater_equal(self, query, value): + if self.value_invalid(value): + return query + return super().filter_greater_equal(query, value) + + def filter_less_than(self, query, value): + if self.value_invalid(value): + return query + return super().filter_less_than(query, value) + + def filter_less_equal(self, query, value): + if self.value_invalid(value): + return query + return super().filter_less_equal(query, value) + + +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): + """ + Boolean filter for SQLAlchemy. + """ + default_verbs = ['is_true', 'is_false', 'is_any'] + + def filter_is_true(self, query, value): + """ + Filter data with an "is true" query. Note that this filter does not + use the value for anything. + """ + return query.filter(self.column == True) + + def filter_is_false(self, query, value): + """ + Filter data with an "is false" query. Note that this filter does not + use the value for anything. + """ + return query.filter(self.column == False) + + +class AlchemyNullableBooleanFilter(AlchemyBooleanFilter): + """ + Boolean filter for SQLAlchemy which is NULL-aware. + """ + 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): + """ + Date filter for SQLAlchemy. + """ + value_renderer_factory = DateValueRenderer + + verb_labels = { + 'equal': "on", + 'not_equal': "not on", + 'greater_than': "after", + '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", + } + + 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', + ] + + 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: + log.warning("invalid date value: {}".format(value)) + 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): + """ + SQLAlchemy filter for datetime values. + """ + + def filter_equal(self, query, value): + """ + Find all dateimes which fall on the given date. + """ + date = self.make_date(value) + if not date: + return query + + start = datetime.datetime.combine(date, datetime.time(0)) + start = make_utc(localtime(self.config, start)) + + stop = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) + stop = make_utc(localtime(self.config, stop)) + + return query.filter(self.column >= start)\ + .filter(self.column < stop) + + def filter_not_equal(self, query, value): + """ + Find all dateimes which do *not* fall on the given date. + """ + date = self.make_date(value) + if not date: + return query + + start = datetime.datetime.combine(date, datetime.time(0)) + start = make_utc(localtime(self.config, start)) + + stop = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) + stop = make_utc(localtime(self.config, stop)) + + return query.filter(sa.or_( + self.column < start, + self.column <= stop)) + + def filter_greater_than(self, query, value): + """ + Find all datetimes which fall after the given date. + """ + date = self.make_date(value) + if not date: + return query + + time = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) + time = make_utc(localtime(self.config, time)) + return query.filter(self.column >= time) + + def filter_greater_equal(self, query, value): + """ + Find all datetimes which fall on or after the given date. + """ + date = self.make_date(value) + if not date: + return query + + time = datetime.datetime.combine(date, datetime.time(0)) + time = make_utc(localtime(self.config, time)) + return query.filter(self.column >= time) + + def filter_less_than(self, query, value): + """ + Find all datetimes which fall before the given date. + """ + date = self.make_date(value) + if not date: + return query + + time = datetime.datetime.combine(date, datetime.time(0)) + time = make_utc(localtime(self.config, time)) + return query.filter(self.column < time) + + def filter_less_equal(self, query, value): + """ + Find all datetimes which fall on or before the given date. + """ + date = self.make_date(value) + if not date: + return query + + time = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) + 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): + """ + SQLAlchemy filter for *local* datetime values. This assumes datetime + values in the database are for local timezone, as opposed to UTC. + """ + + def filter_equal(self, query, value): + """ + Find all dateimes which fall on the given date. + """ + date = self.make_date(value) + if not date: + return query + + start = datetime.datetime.combine(date, datetime.time(0)) + start = localtime(self.config, start, tzinfo=False) + + stop = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) + stop = localtime(self.config, stop, tzinfo=False) + + return query.filter(self.column >= start)\ + .filter(self.column < stop) + + def filter_not_equal(self, query, value): + """ + Find all dateimes which do *not* fall on the given date. + """ + date = self.make_date(value) + if not date: + return query + + start = datetime.datetime.combine(date, datetime.time(0)) + start = localtime(self.config, start, tzinfo=False) + + stop = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) + stop = localtime(self.config, stop, tzinfo=False) + + return query.filter(sa.or_( + self.column < start, + self.column <= stop)) + + def filter_greater_than(self, query, value): + """ + Find all datetimes which fall after the given date. + """ + date = self.make_date(value) + if not date: + return query + + time = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) + time = localtime(self.config, time, tzinfo=False) + return query.filter(self.column >= time) + + def filter_greater_equal(self, query, value): + """ + Find all datetimes which fall on or after the given date. + """ + date = self.make_date(value) + if not date: + return query + + time = datetime.datetime.combine(date, datetime.time(0)) + time = localtime(self.config, time, tzinfo=False) + return query.filter(self.column >= time) + + def filter_less_than(self, query, value): + """ + Find all datetimes which fall before the given date. + """ + date = self.make_date(value) + if not date: + return query + + time = datetime.datetime.combine(date, datetime.time(0)) + time = localtime(self.config, time, tzinfo=False) + return query.filter(self.column < time) + + def filter_less_equal(self, query, value): + """ + Find all datetimes which fall on or before the given date. + """ + date = self.make_date(value) + if not date: + return query + + time = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) + 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', 'equal_any_of', + 'is_null', 'is_not_null'] + + def filter_equal(self, query, value): + """ + Filter data with an equal ('=') query. + """ + if value is None: + return query + + value = value.strip() + if not value: + return query + + try: + return query.filter(self.column.in_(( + GPC(value), + GPC(value, calc_check_digit='upc')))) + except ValueError: + return query + + def filter_not_equal(self, query, value): + """ + Filter data with a not equal ('!=') query. + """ + 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 + # include things which are nothing at all, in our result set. + try: + return query.filter(sa.or_( + ~self.column.in_(( + GPC(value), + GPC(value, calc_check_digit='upc'))), + self.column == None)) + 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) + + # 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, 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.values() + + def filter_verb(self, filtr): + """ + Render the verb selection dropdown for the given filter. + """ + 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 tags.select('{}.verb'.format(filtr.key), filtr.verb, options, **{ + 'class_': 'verb', + 'data-hide-value-for': ' '.join(hide_values)}) + + def filter_value(self, filtr, **kwargs): + """ + Render the value input element(s) for the filter. + """ + 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)]) diff --git a/tailbone/grids/search.py b/tailbone/grids/search.py deleted file mode 100644 index 74ced5ee..00000000 --- a/tailbone/grids/search.py +++ /dev/null @@ -1,383 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Grid Search Filters -""" - -from __future__ import unicode_literals - -import re - -from sqlalchemy import func, or_ - -from webhelpers.html import tags -from webhelpers.html import literal - -from pyramid.renderers import render -from pyramid_simpleform import Form -from pyramid_simpleform.renderers import FormRenderer - -from edbob.util import prettify - -from rattail.core import Object -from rattail.gpc import GPC - - -class SearchFilter(Object): - """ - Base class and default implementation for search filters. - """ - - def __init__(self, name, label=None, **kwargs): - Object.__init__(self, **kwargs) - self.name = name - self.label = label or prettify(name) - - def types_select(self): - types = [ - ('is', "is"), - ('nt', "is not"), - ('lk', "contains"), - ('nl', "doesn't contain"), - (u'sx', u"sounds like"), - (u'nx', u"doesn't sound like"), - ] - options = [] - filter_map = self.search.filter_map[self.name] - for value, label in types: - if value in filter_map: - options.append((value, label)) - return tags.select('filter_type_'+self.name, - self.search.config.get('filter_type_'+self.name), - options, class_='filter-type') - - def value_control(self): - return tags.text(self.name, self.search.config.get(self.name)) - - -class BooleanSearchFilter(SearchFilter): - """ - Boolean search filter. - """ - - def value_control(self): - return tags.select(self.name, self.search.config.get(self.name), - ["True", "False"]) - - -class ChoiceSearchFilter(SearchFilter): - """ - Generic search filter where the user may only select among a specific set - of choices. - """ - - def __init__(self, choices): - self.choices = choices - - def __call__(self, name, label=None, **kwargs): - super(ChoiceSearchFilter, self).__init__(name, label=label, **kwargs) - return self - - def value_control(self): - return tags.select(self.name, self.search.config.get(self.name), self.choices) - - -def EnumSearchFilter(enum): - options = enum.items() - - class EnumSearchFilter(SearchFilter): - def value_control(self): - return tags.select(self.name, self.search.config.get(self.name), options) - - return EnumSearchFilter - - -class SearchForm(Form): - """ - Generic form class which aggregates :class:`SearchFilter` instances. - """ - - def __init__(self, request, filter_map, config, *args, **kwargs): - super(SearchForm, self).__init__(request, *args, **kwargs) - self.filter_map = filter_map - self.config = config - self.filters = {} - - def add_filter(self, filter_): - filter_.search = self - self.filters[filter_.name] = filter_ - - -class SearchFormRenderer(FormRenderer): - """ - Renderer for :class:`SearchForm` instances. - """ - - def __init__(self, form, *args, **kwargs): - super(SearchFormRenderer, self).__init__(form, *args, **kwargs) - self.request = form.request - self.filters = form.filters - self.config = form.config - - def add_filter(self, visible): - options = ['add a filter'] - for f in self.sorted_filters(): - options.append((f.name, f.label)) - return self.select('add-filter', options, - style='display: none;' if len(visible) == len(self.filters) else None) - - def checkbox(self, name, checked=None, **kwargs): - if name.startswith('include_filter_'): - if checked is None: - checked = self.config[name] - return tags.checkbox(name, checked=checked, **kwargs) - if checked is None: - checked = False - return super(SearchFormRenderer, self).checkbox(name, checked=checked, **kwargs) - - def render(self, **kwargs): - kwargs['search'] = self - return literal(render('/grids/search.mako', kwargs)) - - def sorted_filters(self): - return sorted(self.filters.values(), key=lambda x: x.label) - - def text(self, name, **kwargs): - return tags.text(name, value=self.config.get(name), **kwargs) - - -def filter_exact(field): - """ - Convenience function which returns a filter map entry, with typical logic - built in for "exact match" queries applied to ``field``. - """ - - return { - 'is': - lambda q, v: q.filter(field == v) if v else q, - 'nt': - lambda q, v: q.filter(field != v) if v else q, - } - - -def filter_ilike(field): - """ - Convenience function which returns a filter map entry, with typical logic - built in for "ILIKE" queries applied to ``field``. - """ - - def ilike(query, value): - if value: - query = query.filter(field.ilike('%%%s%%' % value)) - return query - - def not_ilike(query, value): - if value: - query = query.filter(or_( - field == None, - ~field.ilike('%%%s%%' % value), - )) - return query - - return {'lk': ilike, 'nl': not_ilike} - - -def filter_int(field): - """ - Returns a filter map entry for an integer field. This provides exact - matching but also strips out non-numeric characters to avoid type errors. - """ - - def filter_is(q, v): - v = re.sub(r'\D', '', v or '') - return q.filter(field == int(v)) if v else q - - def filter_nt(q, v): - v = re.sub(r'\D', '', v or '') - return q.filter(field != int(v)) if v else q - - return {'is': filter_is, 'nt': filter_nt} - - -def filter_soundex(field): - """ - Returns a filter map entry which leverages the `soundex()` SQL function. - """ - - def soundex(query, value): - if value: - query = query.filter(func.soundex(field) == func.soundex(value)) - return query - - def not_soundex(query, value): - if value: - query = query.filter(func.soundex(field) != func.soundex(value)) - return query - - return {u'sx': soundex, u'nx': not_soundex} - - -def filter_ilike_and_soundex(field): - """ - Returns a filter map which provides both the `ilike` and `soundex` - features. - """ - filters = filter_ilike(field) - filters.update(filter_soundex(field)) - return filters - - -def filter_gpc(field): - """ - Returns a filter suitable for a GPC field. - """ - - def filter_is(q, v): - if not v: - return q - try: - return q.filter(field.in_(( - GPC(v), GPC(v, calc_check_digit='upc')))) - except ValueError: - return q - - def filter_not(q, v): - if not v: - return q - try: - return q.filter(~field.in_(( - GPC(v), GPC(v, calc_check_digit='upc')))) - except ValueError: - return q - - return {'is': filter_is, 'nt': filter_not} - - -def get_filter_config(prefix, request, filter_map, **kwargs): - """ - Returns a configuration dictionary for a search form. - """ - - config = {} - - def update_config(dict_, prefix='', exclude_by_default=False): - """ - Updates the ``config`` dictionary based on the contents of ``dict_``. - """ - - for field in filter_map: - if prefix+'include_filter_'+field in dict_: - include = dict_[prefix+'include_filter_'+field] - include = bool(include) and include != '0' - config['include_filter_'+field] = include - elif exclude_by_default: - config['include_filter_'+field] = False - if prefix+'filter_type_'+field in dict_: - config['filter_type_'+field] = dict_[prefix+'filter_type_'+field] - if prefix+field in dict_: - config[field] = dict_[prefix+field] - - # Update config to exclude all filters by default. - for field in filter_map: - config['include_filter_'+field] = False - - # Update config to honor default settings. - config.update(kwargs) - - # Update config with data cached in session. - update_config(request.session, prefix=prefix+'.') - - # Update config with data from GET/POST request. - if request.params.get('filters') == 'true': - update_config(request.params, exclude_by_default=True) - - # Cache filter data in session. - for key in config: - if (not key.startswith('filter_factory_') - and not key.startswith('filter_label_')): - request.session[prefix+'.'+key] = config[key] - - return config - - -def get_filter_map(cls, exact=[], ilike=[], int_=[], **kwargs): - """ - Convenience function which returns a "filter map" for ``cls``. - - ``exact``, if provided, should be a list of field names for which "exact" - filtering is to be allowed. - - ``ilike``, if provided, should be a list of field names for which "ILIKE" - filtering is to be allowed. - - ``int_``, if provided, should be a list of field names for which "integer" - filtering is to be allowed. - - Any remaining ``kwargs`` are assumed to be filter map entries themselves, - and are added directly to the map. - """ - - fmap = {} - for name in exact: - fmap[name] = filter_exact(getattr(cls, name)) - for name in ilike: - fmap[name] = filter_ilike(getattr(cls, name)) - for name in int_: - fmap[name] = filter_int(getattr(cls, name)) - fmap.update(kwargs) - return fmap - - -def get_search_form(request, filter_map, config): - """ - Returns a :class:`SearchForm` instance with a :class:`SearchFilter` for - each filter in ``filter_map``, using configuration from ``config``. - """ - - search = SearchForm(request, filter_map, config) - for field in filter_map: - factory = config.get('filter_factory_%s' % field, SearchFilter) - label = config.get('filter_label_%s' % field) - search.add_filter(factory(field, label=label)) - return search - - -def filter_query(query, config, filter_map, join_map): - """ - Filters the given query according to filter and sorting hints found within - the config dictionary, using the filter and join maps as needed. The - filtered query is returned. - """ - joins = config.setdefault('joins', []) - for key in config: - if key.startswith('include_filter_') and config[key]: - field = key[15:] - value = config.get(field) - if value != '': - if field in join_map and field not in joins: - query = join_map[field](query) - joins.append(field) - fmap = filter_map[field] - filt = fmap[config['filter_type_'+field]] - query = filt(query, value) - return query diff --git a/tailbone/grids/util.py b/tailbone/grids/util.py deleted file mode 100644 index 6ecfd3f8..00000000 --- a/tailbone/grids/util.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ - -""" -Grid Utilities -""" - -from sqlalchemy.orm.attributes import InstrumentedAttribute - -from webhelpers.html import literal - -from pyramid.response import Response - -from .search import SearchFormRenderer - - -def get_sort_config(name, request, **kwargs): - """ - Returns a configuration dictionary for grid sorting. - """ - - # Initial config uses some default values. - config = { - 'dir': 'asc', - 'per_page': 20, - 'page': 1, - } - - # Override with defaults provided by caller. - config.update(kwargs) - - # Override with values from GET/POST request and/or session. - for key in config: - full_key = name+'_'+key - if request.params.get(key): - value = request.params[key] - config[key] = value - request.session[full_key] = value - elif request.session.get(full_key): - value = request.session[full_key] - config[key] = value - - return config - - -def get_sort_map(cls, names=None, **kwargs): - """ - Convenience function which returns a sort map for ``cls``. - - If ``names`` is not specified, the map will include all "standard" fields - present on the mapped class. Otherwise, the map will be limited to only - the fields which are named. - - All remaining ``kwargs`` are assumed to be sort map entries, and will be - added to the map directly. - """ - - smap = {} - if names is None: - names = [] - for attr in cls.__dict__: - obj = getattr(cls, attr) - if isinstance(obj, InstrumentedAttribute): - if obj.key != 'uuid': - names.append(obj.key) - for name in names: - smap[name] = sorter(getattr(cls, name)) - smap.update(kwargs) - return smap - - -def render_grid(grid, search_form=None, **kwargs): - """ - Convenience function to render ``grid`` (which should be a - :class:`tailbone.grids.Grid` instance). - - This "usually" will return a dictionary to be used as context for rendering - the final view template. - - However, if a partial grid is requested (or mandated), then the grid body - will be rendered and a :class:`pyramid.response.Response` object will be - returned instead. - """ - - if grid.partial_only or grid.request.params.get('partial'): - return Response(body=grid.render(), content_type='text/html') - kwargs['grid'] = literal(grid.render()) - if search_form: - kwargs['search'] = SearchFormRenderer(search_form) - return kwargs - - -def sort_query(query, config, sort_map, join_map={}): - """ - Sorts ``query`` according to ``config`` and ``sort_map``. ``join_map`` is - used, if necessary, to join additional tables to the base query. The - sorted query is returned. - """ - - field = config.get('sort') - if not field: - return query - joins = config.setdefault('joins', []) - if field in join_map and field not in joins: - query = join_map[field](query) - joins.append(field) - sort = sort_map[field] - return sort(query, config['dir']) - - -def sorter(field): - """ - Returns a function suitable for a sort map callable, with typical logic - built in for sorting applied to ``field``. - """ - - return lambda q, d: q.order_by(getattr(field, d)()) 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 0c85c522..50b38c30 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -1,45 +1,59 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Template Context Helpers """ -from __future__ import unicode_literals +# start off with all from wuttaweb +from wuttaweb.helpers import * +import os import datetime from decimal import Decimal +from collections import OrderedDict -from webhelpers.html import * -from webhelpers.html.tags import * +from rattail.time import localtime, make_utc +from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal +from rattail.db.util import maxlen -from tailbone.util import pretty_datetime +from tailbone.util import (pretty_datetime, raw_datetime, + render_markdown, + route_exists) def pretty_date(date): """ Render a human-friendly date string. """ - if not date: return '' return date.strftime('%a %d %b %Y') + + +def render_attrs(**attrs): + """ + Convenience wrapper to replace the deprecated + `webhelpers.html.builder.format_attrs()` + """ + HTML.optimize_attrs(attrs) + return HTML.render_attrs(attrs) 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/newgrids/__init__.py b/tailbone/newgrids/__init__.py deleted file mode 100644 index bf47b2cf..00000000 --- a/tailbone/newgrids/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Grids and Friends -""" - -from . import filters -from .core import Grid, GridColumn, GridAction -from .alchemy import AlchemyGrid diff --git a/tailbone/newgrids/alchemy.py b/tailbone/newgrids/alchemy.py deleted file mode 100644 index 6ae25a63..00000000 --- a/tailbone/newgrids/alchemy.py +++ /dev/null @@ -1,202 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -FormAlchemy Grid Classes -""" - -from __future__ import unicode_literals, absolute_import - -import logging - -import sqlalchemy as sa -from sqlalchemy import orm - -from edbob.util import prettify - -from rattail.db.types import GPCType - -import formalchemy -from webhelpers import paginate - -from tailbone.db import Session -from tailbone.newgrids import Grid, GridColumn, filters - - -log = logging.getLogger(__name__) - - -class AlchemyGrid(Grid): - """ - Grid class for use with SQLAlchemy data models. - - Note that this is partially just a wrapper around the FormAlchemy grid, and - that you may use this in much the same way, e.g.:: - - grid = AlchemyGrid(...) - grid.configure( - include=[ - field1, - field2, - ]) - grid.append(field3) - del grid.field1 - grid.field2.set(renderer=SomeFieldRenderer) - """ - - def __init__(self, *args, **kwargs): - super(AlchemyGrid, self).__init__(*args, **kwargs) - fa_grid = formalchemy.Grid(self.model_class, instances=self.data, - session=kwargs.get('session', Session()), - request=self.request) - fa_grid.prettify = prettify - self._fa_grid = fa_grid - - def __delattr__(self, attr): - delattr(self._fa_grid, attr) - - def __getattr__(self, attr): - return getattr(self._fa_grid, attr) - - def default_filters(self): - """ - SQLAlchemy grids are able to provide a default set of filters based on - the column properties mapped to the model class. - """ - filtrs = filters.GridFilterSet() - mapper = orm.class_mapper(self.model_class) - for prop in mapper.iterate_properties: - if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'): - filtrs[prop.key] = self.make_filter(prop.key, prop.columns[0]) - return filtrs - - def make_filter(self, key, column, **kwargs): - """ - Make a filter suitable for use with the given column. - """ - factory = filters.AlchemyGridFilter - if isinstance(column.type, sa.String): - factory = filters.AlchemyStringFilter - elif isinstance(column.type, sa.Numeric): - factory = filters.AlchemyNumericFilter - elif isinstance(column.type, sa.Integer): - factory = filters.AlchemyNumericFilter - elif isinstance(column.type, sa.Boolean): - factory = filters.AlchemyBooleanFilter - elif isinstance(column.type, sa.Date): - factory = filters.AlchemyDateFilter - elif isinstance(column.type, sa.DateTime): - factory = filters.AlchemyDateTimeFilter - elif isinstance(column.type, GPCType): - factory = filters.AlchemyGPCFilter - factory = kwargs.pop('factory', factory) - return factory(key, column=column, config=self.request.rattail_config, **kwargs) - - def iter_filters(self): - """ - Iterate over the grid's complete set of filters. - """ - return self.filters.itervalues() - - def filter_data(self, query): - """ - Filter and return the given data set, according to current settings. - """ - # This overrides the core version only slightly, in that it will only - # invoke a join if any particular filter(s) actually modifies the - # query. The main motivation for this is on the products page, where - # the tricky "vendor (any)" filter has a weird join and causes - # unpredictable results. Now we can skip the join for that unless the - # user actually enters some criteria for it. - for filtr in self.iter_active_filters(): - original = query - query = filtr.filter(query) - if query is not original and filtr.key in self.joiners and filtr.key not in self.joined: - query = self.joiners[filtr.key](query) - self.joined.add(filtr.key) - return query - - def make_sorters(self, sorters): - """ - Returns a mapping of sort options for the grid. Keyword args override - the defaults, which are obtained via the SQLAlchemy ORM. - """ - sorters, updates = {}, sorters - 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 load_settings(self): - """ - When a SQLAlchemy grid loads its settings, it must update the - underlying FormAlchemy grid instance with the final (filtered/etc.) - data set. - """ - super(AlchemyGrid, self).load_settings() - self._fa_grid.rebind(self.make_visible_data(), session=Session(), - request=self.request) - - def paginate_data(self, query): - """ - Paginate the given data set according to current settings, and return - the result. - """ - return paginate.Page( - query, item_count=query.count(), - items_per_page=self.pagesize, page=self.page, - url=paginate.PageURL_WebOb(self.request)) - - def iter_visible_columns(self): - """ - Returns an iterator for all currently-visible columns. - """ - for field in self._fa_grid.render_fields.itervalues(): - column = GridColumn(field.key, label=field.label(), - title=getattr(field, '_column_header_title', None)) - column.field = field - yield column - - def iter_rows(self): - for row in self._fa_grid.rows: - self._fa_grid._set_active(row, orm.object_session(row)) - yield row - - def get_row_key(self, row): - mapper = orm.object_mapper(row) - assert len(mapper.primary_key) == 1 - return getattr(row, mapper.primary_key[0].key) - - def render_cell(self, row, column): - return column.field.render_readonly() diff --git a/tailbone/newgrids/core.py b/tailbone/newgrids/core.py deleted file mode 100644 index 28105487..00000000 --- a/tailbone/newgrids/core.py +++ /dev/null @@ -1,723 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Core Grid Classes -""" - -from __future__ import unicode_literals, absolute_import - -from edbob.util import prettify - -from rattail.db.api import get_setting, save_setting - -from pyramid.renderers import render -from webhelpers.html import HTML, tags -from webhelpers.html.builder import format_attrs - -from tailbone.db import Session -from tailbone.newgrids import filters - - -class Grid(object): - """ - Core grid class. In sore need of documentation. - """ - - def __init__(self, key, request, columns=[], data=[], main_actions=[], more_actions=[], - joiners={}, filterable=False, filters={}, - sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', - pageable=False, default_pagesize=20, default_page=1, - width='auto', checkboxes=False, row_attrs={}, cell_attrs={}, - **kwargs): - self.key = key - self.request = request - self.columns = columns - self.data = data - self.main_actions = main_actions - self.more_actions = more_actions - self.joiners = joiners or {} # use new/different empty dict for each instance - - # Set extra attributes first, in case other init logic depends on any - # of them (i.e. in subclasses). - for kw, val in kwargs.iteritems(): - setattr(self, kw, val) - - self.filterable = filterable - if self.filterable: - self.filters = self.make_filters(filters) - - self.sortable = sortable - if self.sortable: - self.sorters = self.make_sorters(sorters) - self.default_sortkey = default_sortkey - self.default_sortdir = default_sortdir - - self.pageable = pageable - if self.pageable: - self.default_pagesize = default_pagesize - self.default_page = default_page - - self.width = width - self.checkboxes = checkboxes - self.row_attrs = row_attrs or {} - self.cell_attrs = cell_attrs - - def get_default_filters(self): - """ - Returns the default set of filters provided by the grid. - """ - if hasattr(self, 'default_filters'): - if callable(self.default_filters): - return self.default_filters() - return self.default_filters - return filters.GridFilterSet() - - 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. - """ - filters, updates = self.get_default_filters(), filters - if updates: - filters.update(updates) - return filters - - def iter_filters(self): - """ - Iterate over all filters available to the grid. - """ - return self.filters.itervalues() - - def iter_active_filters(self): - """ - Iterate over all *active* filters for the grid. Whether a filter is - active is determined by current grid settings. - """ - for filtr in self.iter_filters(): - if filtr.active: - yield filtr - - def has_active_filters(self): - """ - Returns boolean indicating whether the grid contains any *active* - filters, according to current settings. - """ - for filtr in self.iter_active_filters(): - return True - return False - - 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 updates: - sorters.update(updates) - return sorters - - def make_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') - - 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. - """ - # Initial settings come from class defaults. - 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.filterable: - for filtr in self.iter_filters(): - settings['filter.{0}.active'.format(filtr.key)] = filtr.default_active - settings['filter.{0}.verb'.format(filtr.key)] = filtr.default_verb - settings['filter.{0}.value'.format(filtr.key)] = filtr.default_value - - # If user has default settings on file, apply those first. - if self.user_has_defaults(): - self.apply_user_defaults(settings) - - # 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': - 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') - if self.request_has_settings('sort'): - self.update_sort_settings(settings, 'request') - else: - self.update_sort_settings(settings, '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_page_settings(settings) - - # NOTE: These next two are functionally equivalent, but are kept - # separate to maintain the narrative... - - # If request has no filter/sort settings but does have pager settings, - # 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') - - # 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_page_settings(settings) - - # If no settings were found in request or session, don't store result. - else: - store = False - - # Maybe store settings for next time. - if store: - self.persist_settings(settings, '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') - - # Update ourself and our filters, to reflect settings. - if self.filterable: - for filtr in self.iter_filters(): - filtr.active = settings['filter.{0}.active'.format(filtr.key)] - filtr.verb = settings['filter.{0}.verb'.format(filtr.key)] - filtr.value = settings['filter.{0}.value'.format(filtr.key)] - if self.sortable: - self.sortkey = settings['sortkey'] - self.sortdir = settings['sortdir'] - if self.pageable: - self.pagesize = settings['pagesize'] - self.page = settings['page'] - - def user_has_defaults(self): - """ - Check to see if the current user has default settings on file for this grid. - """ - user = self.request.user - if not user: - return False - - # NOTE: we used to leverage `self.session` here, but sometimes we might - # be showing a grid of data from another system...so always use - # Tailbone Session now, for the settings. hopefully that didn't break - # anything... - session = Session() - if user not in session: - user = session.merge(user) - - # User defaults should have all or nothing, so just check one key. - key = 'tailbone.{}.grid.{}.sortkey'.format(user.uuid, self.key) - return get_setting(session, key) is not None - - def apply_user_defaults(self, settings): - """ - Update the given settings dict with user defaults, if any exist. - """ - def merge(key, coerce=lambda v: v): - skey = 'tailbone.{0}.grid.{1}.{2}'.format(self.request.user.uuid, self.key, key) - value = get_setting(Session(), skey) - settings[key] = coerce(value) - - if self.filterable: - for filtr in self.iter_filters(): - merge('filter.{0}.active'.format(filtr.key), lambda v: v == 'true') - merge('filter.{0}.verb'.format(filtr.key)) - merge('filter.{0}.value'.format(filtr.key)) - - if self.sortable: - merge('sortkey') - merge('sortdir') - - if self.pageable: - 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 - elif type_ == 'sort': - 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): - """ - Determine if the current session contains any settings for the grid. - """ - # Session should have all or nothing, so just check one key. - return 'grid.{0}.sortkey'.format(self.key) in self.request.session - - def get_setting(self, source, settings, key, coerce=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: {0}".format(repr(source))) - - # If source is query string, try that first. - if source == 'request': - value = self.request.GET.get(key) - if value is not None: - try: - value = coerce(value) - except ValueError: - pass - else: - return value - - # Or, if source is session, try that first. - else: - value = self.request.session.get('grid.{0}.{1}'.format(self.key, key)) - if value is not None: - return coerce(value) - - # If source had nothing, try default/existing settings. - value = settings.get(key) - if value is not None: - try: - value = coerce(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.{0}'.format(filtr.key) - - if source == 'request': - # consider filter active if query string contains a value for it - settings['{0}.active'.format(prefix)] = filtr.key in self.request.GET - settings['{0}.verb'.format(prefix)] = self.get_setting( - source, settings, '{0}.verb'.format(filtr.key), default='') - settings['{0}.value'.format(prefix)] = self.get_setting( - source, settings, filtr.key, default='') - - else: # source = session - settings['{0}.active'.format(prefix)] = self.get_setting( - source, settings, '{0}.active'.format(prefix), - coerce=lambda v: unicode(v).lower() == 'true', default=False) - settings['{0}.verb'.format(prefix)] = self.get_setting( - source, settings, '{0}.verb'.format(prefix), default='') - settings['{0}.value'.format(prefix)] = self.get_setting( - source, settings, '{0}.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.{0}.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'] = page - else: - page = self.request.session.get('grid.{0}.page'.format(self.key)) - if page is not None: - settings['page'] = 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': - skey = 'tailbone.{0}.grid.{1}.{2}'.format(self.request.user.uuid, self.key, key) - save_setting(Session(), skey, value(key)) - else: # to == session - skey = 'grid.{0}.{1}'.format(self.key, key) - self.request.session[skey] = value(key) - - if self.filterable: - for filtr in self.iter_filters(): - persist('filter.{0}.active'.format(filtr.key), value=lambda k: unicode(settings[k]).lower()) - persist('filter.{0}.verb'.format(filtr.key)) - persist('filter.{0}.value'.format(filtr.key)) - - if self.sortable: - persist('sortkey') - persist('sortdir') - - if self.pageable: - persist('pagesize') - persist('page') - - def filter_data(self, data): - """ - Filter and return the given data set, according to current settings. - """ - for filtr in self.iter_active_filters(): - if filtr.key in self.joiners and filtr.key not in self.joined: - data = self.joiners[filtr.key](data) - self.joined.add(filtr.key) - data = filtr.filter(data) - 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. Note that the default implementation does nothing. - """ - return data - - 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 - - def render_complete(self, template='/newgrids/complete.mako', **kwargs): - """ - Render the complete grid, including filters. - """ - kwargs['grid'] = self - kwargs.setdefault('allow_save_defaults', True) - return render(template, kwargs) - - def render_grid(self, template='/newgrids/grid.mako', **kwargs): - """ - Render the grid to a Unicode string, using the specified template. - Addition kwargs are passed along as context to the template. - """ - kwargs['grid'] = self - kwargs['format_attrs'] = format_attrs - return render(template, kwargs) - - def render_filters(self, template='/newgrids/filters.mako', **kwargs): - """ - Render the filters to a Unicode string, using the specified template. - Additional kwargs are passed along as context to the template. - """ - # Provide default data to filters form, so renderer can do some of the - # work for us. - data = {} - for filtr in self.iter_active_filters(): - data['{0}.active'.format(filtr.key)] = filtr.active - data['{0}.verb'.format(filtr.key)] = filtr.verb - data[filtr.key] = filtr.value - - form = filters.GridFiltersForm(self.request, self.filters, defaults=data) - - kwargs['request'] = self.request - kwargs['grid'] = self - kwargs['form'] = filters.GridFiltersFormRenderer(form) - return render(template, kwargs) - - def get_div_attrs(self): - """ - Returns a properly-formatted set of attributes which will be applied to - the parent ``<div>`` element which contains the grid, when the grid is - rendered. - """ - classes = ['newgrid'] - if self.width == 'full': - classes.append('full') - if self.checkboxes: - classes.append('selectable') - return {'class_': ' '.join(classes), - 'data-url': self.request.current_route_url(_query=None), - 'data-permalink': self.request.current_route_url()} - - def iter_visible_columns(self): - """ - Returns an iterator for all currently-visible columns. - """ - return iter(self.columns) - - def column_header(self, column): - """ - Render a header (``<th>`` element) for a grid column. - """ - kwargs = {'c': column.label} - if self.sortable and column.key in self.sorters: - if column.key == self.sortkey: - kwargs['class_'] = 'sortable sorted {0}'.format(self.sortdir) - else: - kwargs['class_'] = 'sortable' - kwargs['data-sortkey'] = column.key - kwargs['c'] = tags.link_to(column.label, '#', title=column.title) - elif column.title: - kwargs['c'] = HTML.tag('span', title=column.title, c=column.label) - return HTML.tag('th', **kwargs) - - @property - def show_actions_column(self): - """ - Whether or not an "Actions" column should be rendered for the grid. - """ - return bool(self.main_actions or self.more_actions) - - 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(link + HTML.tag('div', class_='more', c=more_actions)) - return HTML.literal('').join(main_actions) - - def render_action(self, action, row, i): - """ - Renders an action menu item (link) for the given row. - """ - url = action.get_url(row, i) - if url: - kwargs = {'class_': action.key} - if action.icon: - icon = HTML.tag('span', class_='ui-icon ui-icon-{}'.format(action.icon)) - return tags.link_to(icon + action.label, url, **kwargs) - return tags.link_to(action.label, url, **kwargs) - - def iter_rows(self): - return self.make_visible_data() - - def get_row_attrs(self, row, i): - """ - Returns a dict of HTML attributes which is to be applied to the row's - ``<tr>`` element. Note that ``i`` will be a 1-based index value for - the row within its table. The meaning of ``row`` is basically not - defined; it depends on the type of data the grid deals with. - """ - if callable(self.row_attrs): - return self.row_attrs(row, i) - return self.row_attrs - - # def get_row_class(self, row, i): - # class_ = self.default_row_class(row, i) - # if callable(self.extra_row_class): - # extra = self.extra_row_class(row, i) - # if extra: - # class_ = '{0} {1}'.format(class_, extra) - # return class_ - - def get_row_key(self, row): - raise NotImplementedError - - def checkbox(self, row): - return True - - def checked(self, row): - return False - - def render_checkbox(self, row): - """ - Returns a boolean indicating whether ot not a checkbox should be - rendererd for the given row. Default implementation returns ``True`` - in all cases. - """ - if not self.checkbox(row): - return '' - return tags.checkbox('checkbox-{0}-{1}'.format(self.key, self.get_row_key(row)), - checked=self.checked(row)) - - def get_cell_attrs(self, row, column): - """ - Returns a dictionary of HTML attributes which should be applied to the - ``<td>`` element in which the given row and column "intersect". - """ - if callable(self.cell_attrs): - return self.cell_attrs(row, column) - return self.cell_attrs - - def render_cell(self, row, column): - return column.render(row[column.key]) - - def get_pagesize_options(self): - # TODO: Make configurable or something... - return [5, 10, 20, 50, 100] - - -class GridColumn(object): - """ - Simple class to represent a column displayed within a grid table. - - .. attribute:: key - - Key for the column, within the context of the grid. - - .. attribute:: label - - Human-facing label for the column, i.e. displayed in the header. - """ - - def __init__(self, key, label=None, title=None): - self.key = key - self.label = label or prettify(key) - self.title = title - - def render(self, value): - """ - Render the given value, to be displayed within a grid cell. - """ - return unicode(value) - - -class GridAction(object): - """ - Represents an action available to a grid. This is used to construct the - 'actions' column when rendering the grid. - """ - - def __init__(self, key, label=None, url='#', icon=None): - self.key = key - self.label = label or prettify(key) - self.icon = icon - self.url = url - - 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 diff --git a/tailbone/newgrids/filters.py b/tailbone/newgrids/filters.py deleted file mode 100644 index b952e8a0..00000000 --- a/tailbone/newgrids/filters.py +++ /dev/null @@ -1,586 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Grid Filters -""" - -from __future__ import unicode_literals, absolute_import - -import datetime -import logging - -import sqlalchemy as sa - -from edbob.util import prettify - -from rattail.gpc import GPC -from rattail.util import OrderedDict -from rattail.core import UNSPECIFIED -from rattail.time import localtime, make_utc - -from pyramid_simpleform import Form -from pyramid_simpleform.renderers import FormRenderer -from webhelpers.html import HTML, tags - - -log = logging.getLogger(__name__) - - -class FilterValueRenderer(object): - """ - Base class for all filter renderers. - """ - - @property - def name(self): - return self.filter.key - - def render(self, value=None, **kwargs): - """ - Render the filter input element(s) as HTML. Default implementation - uses a simple text input. - """ - return tags.text(self.name, value=value, **kwargs) - - -class DefaultValueRenderer(FilterValueRenderer): - """ - Default / fallback renderer. - """ - - -class NumericValueRenderer(FilterValueRenderer): - """ - Input renderer for numeric values. - """ - - def render(self, value=None, **kwargs): - return tags.text(self.name, value=value, type='number', **kwargs) - - -class DateValueRenderer(FilterValueRenderer): - """ - Input renderer for date values. - """ - - def render(self, value=None, **kwargs): - return tags.text(self.name, value=value, type='date', **kwargs) - - -class ChoiceValueRenderer(FilterValueRenderer): - """ - Renders value input as a dropdown/selectmenu of available choices. - """ - - def __init__(self, choices): - self.choices = choices - - def render(self, value=None, **kwargs): - return tags.select(self.name, [value], self.choices, **kwargs) - - -class EnumValueRenderer(ChoiceValueRenderer): - """ - Renders value input as a dropdown/selectmenu of available choices. - """ - - def __init__(self, enum): - sorted_keys = sorted(enum, key=lambda k: enum[k].lower()) - self.choices = [(k, enum[k]) for k in sorted_keys] - - -class GridFilter(object): - """ - Represents a filter available to a grid. This is used to construct the - 'filters' section when rendering the index page template. - """ - verb_labels = { - 'is_any': "is any", - 'equal': "equal to", - 'not_equal': "not equal to", - 'greater_than': "greater than", - 'greater_equal': "greater than or equal to", - 'less_than': "less than", - 'less_equal': "less than or equal to", - 'is_null': "is null", - 'is_not_null': "is not null", - 'is_true': "is true", - 'is_false': "is false", - 'contains': "contains", - 'does_not_contain': "does not contain", - } - - valueless_verbs = ['is_any', 'is_null', 'is_not_null', 'is_true', 'is_false'] - - value_renderer_factory = DefaultValueRenderer - - def __init__(self, key, label=None, verbs=None, value_renderer=None, - default_active=False, default_verb=None, default_value=None, **kwargs): - self.key = key - self.label = label or prettify(key) - 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(): - setattr(self, key, value) - - def __repr__(self): - return "GridFilter({0})".format(repr(self.key)) - - def get_default_verbs(self): - """ - Returns the set of verbs which will be used by default, i.e. unless - overridden by constructor args etc. - """ - verbs = getattr(self, 'default_verbs', None) - if verbs: - if callable(verbs): - return verbs() - return verbs - return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'] - - def set_value_renderer(self, renderer): - """ - Set the value renderer for the filter, post-construction. - """ - if not isinstance(renderer, FilterValueRenderer): - renderer = renderer() - renderer.filter = self - self.value_renderer = renderer - - def filter(self, data, verb=None, value=UNSPECIFIED): - """ - Filter the given data set according to a verb/value pair. If no verb - and/or value is specified by the caller, the filter will use its own - current verb/value by default. - """ - verb = verb or self.verb - value = value if value is not UNSPECIFIED else self.value - filtr = getattr(self, 'filter_{0}'.format(verb), None) - if not filtr: - raise ValueError("Unknown filter verb: {0}".format(repr(verb))) - return filtr(data, value) - - def filter_is_any(self, data, value): - """ - Special no-op filter which does no actual filtering. Useful in some - cases to add an "ineffective" option to the verb list for a given grid - filter. - """ - return data - - def render_value(self, value=UNSPECIFIED, **kwargs): - """ - Render the HTML needed to expose the filter's value for user input. - """ - if value is UNSPECIFIED: - value = self.value - kwargs['filtr'] = self - return self.value_renderer.render(value=value, **kwargs) - - -class AlchemyGridFilter(GridFilter): - """ - Base class for SQLAlchemy grid filters. - """ - - def __init__(self, *args, **kwargs): - self.column = kwargs.pop('column') - super(AlchemyGridFilter, self).__init__(*args, **kwargs) - - def filter_equal(self, query, value): - """ - Filter data with an equal ('=') query. - """ - if value is None or value == '': - return query - return query.filter(self.column == value) - - def filter_not_equal(self, query, value): - """ - Filter data with a not eqaul ('!=') query. - """ - if value is None or value == '': - return query - - # When saying something is 'not equal' to something else, we must also - # include things which are nothing at all, in our result set. - return query.filter(sa.or_( - self.column == None, - self.column != value, - )) - - def filter_is_null(self, query, value): - """ - Filter data with an 'IS NULL' query. Note that this filter does not - use the value for anything. - """ - return query.filter(self.column == None) - - def filter_is_not_null(self, query, value): - """ - Filter data with an 'IS NOT NULL' query. Note that this filter does - not use the value for anything. - """ - return query.filter(self.column != None) - - def filter_greater_than(self, query, value): - """ - Filter data with a greater than ('>') query. - """ - if value is None or value == '': - return query - return query.filter(self.column > value) - - def filter_greater_equal(self, query, value): - """ - Filter data with a greater than or equal ('>=') query. - """ - if value is None or value == '': - return query - return query.filter(self.column >= value) - - def filter_less_than(self, query, value): - """ - Filter data with a less than ('<') query. - """ - if value is None or value == '': - return query - return query.filter(self.column < value) - - def filter_less_equal(self, query, value): - """ - Filter data with a less than or equal ('<=') query. - """ - if value is None or value == '': - return query - return query.filter(self.column <= value) - - -class AlchemyStringFilter(AlchemyGridFilter): - """ - String filter for SQLAlchemy. - """ - - def default_verbs(self): - """ - Expose contains / does-not-contain verbs in addition to core. - """ - return ['contains', 'does_not_contain', - 'equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'] - - def filter_contains(self, query, value): - """ - Filter data with a full 'ILIKE' query. - """ - if value is None or value == '': - return query - return query.filter(sa.and_( - *[self.column.ilike('%{0}%'.format(v)) for v in value.split()])) - - def filter_does_not_contain(self, query, value): - """ - Filter data with a full 'NOT ILIKE' query. - """ - if value is None or value == '': - return query - - # 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()]), - )) - - -class AlchemyNumericFilter(AlchemyGridFilter): - """ - Numeric filter for SQLAlchemy. - """ - value_renderer_factory = NumericValueRenderer - - def default_verbs(self): - """ - Expose greater-than / less-than verbs in addition to core. - """ - return ['equal', 'not_equal', 'greater_than', 'greater_equal', - 'less_than', 'less_equal', 'is_null', 'is_not_null'] - - -class AlchemyBooleanFilter(AlchemyGridFilter): - """ - Boolean filter for SQLAlchemy. - """ - default_verbs = ['is_true', 'is_false', 'is_null', 'is_not_null', 'is_any'] - - def filter_is_true(self, query, value): - """ - Filter data with an "is true" query. Note that this filter does not - use the value for anything. - """ - return query.filter(self.column == True) - - def filter_is_false(self, query, value): - """ - Filter data with an "is false" query. Note that this filter does not - use the value for anything. - """ - return query.filter(self.column == False) - - -class AlchemyDateFilter(AlchemyGridFilter): - """ - Date filter for SQLAlchemy. - """ - value_renderer_factory = DateValueRenderer - - verb_labels = { - 'equal': "on", - 'not_equal': "not on", - 'greater_than': "after", - 'greater_equal': "on or after", - 'less_than': "before", - 'less_equal': "on or before", - 'is_null': "is null", - 'is_not_null': "is not null", - 'is_any': "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', 'is_null', 'is_not_null', 'is_any'] - - def make_date(self, value): - """ - Convert user input to a proper ``datetime.date`` object. - """ - if value: - try: - dt = datetime.datetime.strptime(value, '%Y-%m-%d') - except ValueError: - log.warning("invalid date value: {}".format(value)) - else: - return dt.date() - - -class AlchemyDateTimeFilter(AlchemyDateFilter): - """ - SQLAlchemy filter for datetime values. - """ - - def filter_equal(self, query, value): - """ - Find all dateimes which fall on the given date. - """ - date = self.make_date(value) - if not date: - return query - - start = datetime.datetime.combine(date, datetime.time(0)) - start = make_utc(localtime(self.config, start)) - - stop = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) - stop = make_utc(localtime(self.config, stop)) - - return query.filter(self.column >= start)\ - .filter(self.column < stop) - - def filter_not_equal(self, query, value): - """ - Find all dateimes which do *not* fall on the given date. - """ - date = self.make_date(value) - if not date: - return query - - start = datetime.datetime.combine(date, datetime.time(0)) - start = make_utc(localtime(self.config, start)) - - stop = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) - stop = make_utc(localtime(self.config, stop)) - - return query.filter(sa.or_( - self.column < start, - self.column <= stop)) - - def filter_greater_than(self, query, value): - """ - Find all datetimes which fall after the given date. - """ - date = self.make_date(value) - if not date: - return query - - time = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) - time = make_utc(localtime(self.config, time)) - return query.filter(self.column >= time) - - def filter_greater_equal(self, query, value): - """ - Find all datetimes which fall on or after the given date. - """ - date = self.make_date(value) - if not date: - return query - - time = datetime.datetime.combine(date, datetime.time(0)) - time = make_utc(localtime(self.config, time)) - return query.filter(self.column >= time) - - def filter_less_than(self, query, value): - """ - Find all datetimes which fall before the given date. - """ - date = self.make_date(value) - if not date: - return query - - time = datetime.datetime.combine(date, datetime.time(0)) - time = make_utc(localtime(self.config, time)) - return query.filter(self.column < time) - - def filter_less_equal(self, query, value): - """ - Find all datetimes which fall on or before the given date. - """ - date = self.make_date(value) - if not date: - return query - - time = datetime.datetime.combine(date + datetime.timedelta(days=1), datetime.time(0)) - time = make_utc(localtime(self.config, time)) - return query.filter(self.column < time) - - -class AlchemyGPCFilter(AlchemyGridFilter): - """ - GPC filter for SQLAlchemy. - """ - default_verbs = ['equal', 'not_equal'] - - def filter_equal(self, query, value): - """ - Filter data with an equal ('=') query. - """ - if value is None or value == '': - return query - try: - return query.filter(self.column.in_(( - GPC(value), - GPC(value, calc_check_digit='upc')))) - except ValueError: - return query - - def filter_not_equal(self, query, value): - """ - Filter data with a not eqaul ('!=') query. - """ - if value is None or value == '': - return query - - # When saying something is 'not equal' to something else, we must also - # include things which are nothing at all, in our result set. - try: - return query.filter(sa.or_( - ~self.column.in_(( - GPC(value), - GPC(value, calc_check_digit='upc'))), - self.column == None)) - except ValueError: - return query - - -class GridFilterSet(OrderedDict): - """ - Collection class for :class:`GridFilter` instances. - """ - - -class GridFiltersForm(Form): - """ - Form for grid filters. - """ - - def __init__(self, request, filters, *args, **kwargs): - super(GridFiltersForm, self).__init__(request, *args, **kwargs) - self.filters = filters - - 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 - :func:`webhelpers:webhelpers.HTML.tag()` function. - """ - 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) - - 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))) - 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, **{ - 'class_': 'verb', - 'data-hide-value-for': ' '.join(hide_values)}) - - def filter_value(self, filtr, **kwargs): - """ - Render the value input element(s) for the filter. - """ - 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)) diff --git a/tailbone/progress.py b/tailbone/progress.py index 86c7666d..5c45f390 100644 --- a/tailbone/progress.py +++ b/tailbone/progress.py @@ -1,54 +1,82 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ - """ Progress Indicator """ -from __future__ import unicode_literals +from __future__ import unicode_literals, absolute_import + +import os +import warnings + +from rattail.progress import ProgressBase from beaker.session import Session -def get_progress_session(request, key): +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 = '{0}.progress.{1}'.format(request.session.id, key) - return Session(request, id, 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') + 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): - self.session = get_progress_session(request, key) + def __init__(self, request, key, session_type=None, ws=False): + self.key = key + 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() @@ -56,6 +84,7 @@ class SessionProgress(object): self.clear() self.session['message'] = message self.session['maximum'] = maximum + self.session['maximum_display'] = '{:,d}'.format(maximum) self.session['value'] = 0 self.session.save() return self @@ -75,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/inventory_worksheet.mako b/tailbone/reports/inventory_worksheet.mako index 6277a94d..8979eed5 100644 --- a/tailbone/reports/inventory_worksheet.mako +++ b/tailbone/reports/inventory_worksheet.mako @@ -83,7 +83,7 @@ <tr> <td class="upc">${get_upc(product)}</td> <td class="brand">${product.brand or ''}</td> - <td class="description">${product.description}</td> + <td class="description">${product.description} ${product.size}</td> <td class="count"> </td> </tr> % endfor 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 7ea2cca3..57700b80 100644 --- a/tailbone/static/__init__.py +++ b/tailbone/static/__init__.py @@ -1,31 +1,31 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Static Assets """ -from __future__ import unicode_literals - 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 fff3719c..0fa02dbb 100644 --- a/tailbone/static/css/base.css +++ b/tailbone/static/css/base.css @@ -1,101 +1,14 @@ /****************************** - * General + * tweaks for root user ******************************/ -* { - margin: 0px; -} - -body { - font-family: Verdana, Arial, sans-serif; - font-size: 11pt; -} - -a { - color: #3D6E1C; - 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; -} - -div.error { - color: #dd6666; +.navbar .navbar-end .navbar-link.root-user, +.navbar .navbar-end .navbar-link.root-user:hover, +.navbar .navbar-end .navbar-link.root-user.is_active, +.navbar .navbar-end .navbar-item.root-user, +.navbar .navbar-end .navbar-item.root-user:hover, +.navbar .navbar-end .navbar-item.root-user.is_active { + background-color: red; font-weight: bold; - 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; } 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 new file mode 100644 index 00000000..662aae07 --- /dev/null +++ b/tailbone/static/css/diffs.css @@ -0,0 +1,42 @@ + +table.diff { + 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%; +} + +.ui-dialog-content table.diff { + color: black; +} + +table.diff th, +table.diff td { + border-bottom: 1px solid Black; + border-right: 1px solid Black; + 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.new td.new-value, +table.diff.dirty tr.diff td.new-value { + background-color: #cfc; +} + +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 b3cec279..de4b1ebe 100644 --- a/tailbone/static/css/forms.css +++ b/tailbone/static/css/forms.css @@ -1,113 +1,61 @@ /****************************** - * Form Wrapper + * forms ******************************/ -div.form-wrapper { - overflow: auto; +/* 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%; +} /****************************** - * Context Menu + * field-wrappers ******************************/ -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; -} - - -/****************************** - * Fieldsets - ******************************/ - -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; } -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 094effb0..42da832c 100644 --- a/tailbone/static/css/grids.css +++ b/tailbone/static/css/grids.css @@ -1,179 +1,337 @@ +/******************************************************************************** + * grids.css + * + * Style tweaks for the new grids. + ********************************************************************************/ + + /****************************** - * Grid Header + * header table ******************************/ -table.grid-header { - padding-bottom: 5px; +.grid-wrapper .grid-header td.filters { + vertical-align: bottom; width: 100%; } +.grid-wrapper .grid-header td.menu { + padding: 0.5em; + vertical-align: top; + white-space: nowrap; +} -/****************************** - * Form (Filters etc.) - ******************************/ +.grid-wrapper .grid-header #context-menu { + margin: 0; +} -table.grid-header td.form { +.grid-tools { + display: flex; + gap: 0.5rem; +} + +.grid-wrapper .grid-header td.tools { + margin: 0; + padding: 0; + text-align: right; vertical-align: bottom; + white-space: nowrap; +} + +.grid-wrapper .grid-header td.tools p { + line-height: 2em; + margin: 0; + padding: 0 0.5em 0 0; +} + +.grid-wrapper .grid-header td.tools form { + display: inline-block; } /****************************** - * Context Menu + * filters ******************************/ -table.grid-header td.context-menu { +.grid-wrapper .newfilters { + margin: 0; +} + +.grid-wrapper .newfilters fieldset { + margin: 0; + padding: 1px 5px 5px 5px; + width: 80%; +} + +.grid-wrapper .newfilters .filter { + margin-bottom: 2px; +} + +.grid-wrapper .newfilters .filter:last-child { + margin-bottom: 0; +} + +.grid-wrapper .newfilters .filter .toggle { + margin: 0; + text-align: left; + width: 15em; +} + +.grid-wrapper .newfilters .ui-button-text { + line-height: 1.4em; +} + +.grid-wrapper .ui-button-text-icon-primary .ui-button-text { + padding: 0.2em 1em 0.2em 2.1em; +} + +.grid-wrapper .ui-selectmenu-button .ui-selectmenu-text { + padding: 0.2em 2.1em 0.2em 1em; +} + +.grid-wrapper .newfilters .filter label { + font-weight: bold; + padding: 0.4em 0.2em; + position: relative; + top: -14px; +} + +.grid-wrapper .newfilters .filter .value { + display: inline-block; vertical-align: top; } -table.grid-header td.context-menu ul { - list-style-type: none; - margin: 0px; - text-align: right; +.grid-wrapper .newfilters .filter .value input { + height: 19px; } -table.grid-header td.context-menu ul li { - line-height: 2em; +.grid-wrapper .newfilters .filter .inputs { + display: inline-block; + /* TODO: Would be nice not to hard-code a height here... */ + height: 26px; + vertical-align: top; } -/****************************** - * Tools - ******************************/ - -table.grid-header td.tools { - padding-bottom: 10px; - text-align: right; - vertical-align: bottom; +.grid-wrapper .newfilters .buttons { + margin: 0.5em 0 0 0; + padding: 0; } -table.grid-header td.tools div.buttons button { - margin-left: 5px; +.grid-wrapper .newfilters #add-filter-button { + margin-right: 5px; + vertical-align: middle; } /****************************** - * Grid + * table ******************************/ -div.grid { +.grid { clear: both; + margin-top: 1em; } -div.grid table { - background-color: White; - border-top: 1px solid black; - border-left: 1px solid black; +.grid.no-border { + margin-top: 0; +} + +.grid table { + background-color: white; + border: 1px solid black; border-collapse: collapse; - font-size: 9pt; + font-size: 10pt; line-height: normal; white-space: nowrap; } -div.grid.full table { +.grid.full table { width: 100%; } -div.grid.no-border table { +.grid.half table { + width: 50%; +} + +.grid.no-border table { border-left: none; border-top: none; } -div.grid table th, -div.grid table td { + +/****************************** + * thead + ******************************/ + +.grid thead th, +.grid tr.header td { border-right: 1px solid black; border-bottom: 1px solid black; + font-weight: bold; padding: 2px 3px; + text-align: center; } -div.grid table th.sortable a { +/* .grid table thead th:last-child { */ +/* border-right: none; */ +/* } */ + +.grid tr.header a { display: block; padding-right: 18px; } -div.grid table th.sorted { +.grid tr.header .asc, +.grid tr.header .dsc { background-position: right center; background-repeat: no-repeat; } -div.grid table th.sorted.asc { +.grid tr.header .asc { background-image: url(../img/sort_arrow_up.png); } -div.grid table th.sorted.desc { +.grid tr.header .dsc { background-image: url(../img/sort_arrow_down.png); } -div.grid table tbody td { - text-align: left; + +/****************************** + * tbody + ******************************/ + +.grid tbody td { + padding: 5px 6px; } -div.grid table tbody td.center { - text-align: center; +.grid.selectable tbody td { + cursor: default; } -div.grid table tbody td.right { - float: none; - text-align: right; -} - -div.grid table tr.odd { +.grid tr.odd { background-color: #e0e0e0; } -div.grid table tbody tr.hovering { +.grid tr:not(.header).hovering { background-color: #bbbbbb; } -div.grid table tbody tr td.checkbox { +.grid tr:not(.header).warning.odd { + background-color: #fcc; +} + +.grid tr:not(.header).warning.even { + background-color: #ebb; +} + +.grid tr:not(.header).warning.hovering { + background-color: #daa; +} + +.grid tr:not(.header).notice.odd { + background-color: #fe8; +} + +.grid tr:not(.header).notice.even { + background-color: #fd6; +} + +.grid tr:not(.header).notice.hovering { + background-color: #ec7; +} + +.grid tr:not(.header) td.checkbox { text-align: center; } -div.grid table tbody tr td.view, -div.grid table tbody tr td.edit, -div.grid table tbody tr td.save, -div.grid table tbody tr td.delete { - background-repeat: no-repeat; - background-position: center; - cursor: pointer; - min-width: 18px; - text-align: center; - width: 18px; +.grid tr:not(.header).selected.odd { + background-color: #507aaa; } -div.grid table tbody tr td.view { - background-image: url(../img/view.png); +.grid tr:not(.header).selected.even { + background-color: #628db6; } -div.grid table tbody tr td.edit { - background-image: url(../img/edit.png); +.grid tr:not(.header).selected.hovering { + background-color: #3e5b76; + color: white; } -div.grid table tbody tr td.save { - background-image: url(../img/save.png); +.grid tr:not(.header).selected a { + color: #cccccc; } -div.grid table tbody tr td.delete { - background-image: url(../img/delete.png); + +/****************************** + * main actions + ******************************/ + +a.grid-action { + white-space: nowrap; } -div.pager { +.grid .actions { + width: 1px; +} + +.grid .actions a { + margin: 0 5px 0 0; + position: relative; + top: -2px; +} + +.grid .actions a:last-child { + margin: 0; +} + +.grid .actions .ui-icon { + display: inline-block; + position: relative; + top: 3px; +} + + +/****************************** + * more actions + ******************************/ + +.grid .actions div.more { + background-color: white; + border: 1px solid black; + display: none; + padding: 3px 10px 3px 5px; + position: absolute; + z-index: 1; +} + +.grid .actions .more a { + display: block; + padding: 2px 0; +} + + +/****************************** + * pager + ******************************/ + +.pager { margin-bottom: 20px; margin-top: 5px; } -div.pager p { +.pager p { font-size: 10pt; margin: 0px; } -div.pager p.showing { +.pager .showing { float: left; } -div.pager #grid-page-count { +.pager #grid-page-count { font-size: 8pt; } -div.pager p.page-links { +.pager .page-links { float: right; } 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.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 3bb6aaac..ef5c5352 100644 --- a/tailbone/static/css/layout.css +++ b/tailbone/static/css/layout.css @@ -1,109 +1,158 @@ /****************************** - * Main Layout + * main layout ******************************/ -html, body, #body-wrapper { - height: 100%; +body { + display: flex; + flex-direction: column; + min-height: 100vh; } -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; +.content-wrapper { + display: flex; + flex: 1; + flex-direction: column; + justify-content: space-between; } /****************************** - * Header + * header ******************************/ -#header h1 { - float: left; - font-size: 25px; - margin: 0px; +/* this is the one in the very top left of screen, next to logo and linked to +the home page */ +#global-header-title { + margin-left: 0.3rem; } -#header h1.title { - font-size: 20px; - margin-left: 10px; +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 div.login { - float: right; +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; +} /****************************** - * Logo + * content ******************************/ -#logo { - display: block; - margin: 40px auto; +#page-body { + padding: 0.4em; } - /****************************** * context menu ******************************/ #context-menu { - float: right; - list-style-type: none; + 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; +} + +/****************************** + * markdown + ******************************/ + +.rendered-markdown p, +.rendered-markdown ul { + margin-bottom: 1rem; +} + +.rendered-markdown .codehilite { + margin-bottom: 2rem; +} + +/****************************** + * fix datepicker within modals + * TODO: someday this may not be necessary? cf. + * https://github.com/buefy/buefy/issues/292#issuecomment-347365637 + ******************************/ + +.modal .animation-content .modal-card { + overflow: visible !important; +} + +.modal-card-body { + overflow: visible !important; +} + +/* 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 .red { + color: red; + font-weight: bold; } diff --git a/tailbone/static/css/login.css b/tailbone/static/css/login.css deleted file mode 100644 index 19efe6b2..00000000 --- a/tailbone/static/css/login.css +++ /dev/null @@ -1,34 +0,0 @@ - -/****************************** - * login.css - ******************************/ - -#logo { - margin: auto; -} - -div.form { - margin: auto; - float: none; - text-align: center; -} - -div.field-wrapper { - margin: 10px auto; - width: 300px; -} - -div.field-wrapper label { - margin: 0px; - padding-top: 3px; - text-align: right; - width: 100px; -} - -div.field-wrapper input { - width: 150px; -} - -div.buttons input { - margin: auto 5px; -} diff --git a/tailbone/static/css/newgrids.css b/tailbone/static/css/newgrids.css deleted file mode 100644 index 66351494..00000000 --- a/tailbone/static/css/newgrids.css +++ /dev/null @@ -1,251 +0,0 @@ - -/******************************************************************************** - * newgrids.css - * - * Style tweaks for the new grids. - ********************************************************************************/ - - -/****************************** - * header table - ******************************/ - -.newgrid-wrapper .grid-header td.filters { - vertical-align: bottom; - width: 100%; -} - -.newgrid-wrapper .grid-header td.menu { - padding: 0.5em; - vertical-align: top; - white-space: nowrap; -} - -.newgrid-wrapper .grid-header td.tools { - margin: 0; - padding: 0; - vertical-align: bottom; - white-space: nowrap; -} - -/****************************** - * filters - ******************************/ - -.newgrid-wrapper .newfilters { - margin: 0; -} - -.newgrid-wrapper .newfilters fieldset { - margin: 0; - padding: 1px 5px 5px 5px; - width: 80%; -} - -.newgrid-wrapper .newfilters .filter { - margin-bottom: 2px; -} - -.newgrid-wrapper .newfilters .filter:last-child { - margin-bottom: 0; -} - -.newgrid-wrapper .newfilters .filter .toggle { - margin: 0; - text-align: left; - width: 15em; -} - -.newgrid-wrapper .newfilters .ui-button-text { - line-height: 1.4em; -} - -.newgrid-wrapper .ui-button-text-icon-primary .ui-button-text { - padding: 0.2em 1em 0.2em 2.1em; -} - -.newgrid-wrapper .ui-selectmenu-button .ui-selectmenu-text { - padding: 0.2em 2.1em 0.2em 1em; -} - -.newgrid-wrapper .newfilters .filter label { - font-weight: bold; - padding: 0.4em 0.2em; - position: relative; - top: -14px; -} - -.newgrid-wrapper .newfilters .filter .value { - display: inline-block; - vertical-align: top; -} - -.newgrid-wrapper .newfilters .filter .value input { - height: 19px; -} - -.newgrid-wrapper .newfilters .filter .inputs { - display: inline-block; - /* TODO: Would be nice not to hard-code a height here... */ - height: 26px; - vertical-align: top; -} - -.newgrid-wrapper .newfilters .buttons { - margin: 0.5em 0 0 0; - padding: 0; -} - -.newgrid-wrapper .newfilters #add-filter-button { - margin-right: 5px; - vertical-align: middle; -} - - -/****************************** - * table - ******************************/ - -.newgrid { - clear: both; - margin-top: 0.3em; -} - -.newgrid table { - background-color: white; - border: 1px solid black; - border-collapse: collapse; - font-size: 10pt; - line-height: normal; - white-space: nowrap; -} - -.newgrid.full table { - width: 100%; -} - - -/****************************** - * thead - ******************************/ - -.newgrid table thead th { - border-right: 1px solid black; - border-bottom: 1px solid black; - padding: 2px 3px; -} - -.newgrid table thead th:last-child { - border-right: none; -} - -.newgrid table thead th.sortable a { - display: block; - padding-right: 18px; -} - -.newgrid table thead th.sorted { - background-position: right center; - background-repeat: no-repeat; -} - -.newgrid table thead th.sorted.asc { - background-image: url(../img/sort_arrow_up.png); -} - -.newgrid table thead th.sorted.desc { - background-image: url(../img/sort_arrow_down.png); -} - - -/****************************** - * tbody - ******************************/ - -.newgrid tbody td { - padding: 5px 6px; -} - -.newgrid.selectable tbody td { - cursor: default; -} - -.newgrid tbody tr:nth-child(odd) { - background-color: #e0e0e0; -} - -.newgrid tbody tr.hovering { - background-color: #bbbbbb; -} - -.newgrid tbody tr.notice { - background-color: #fd6; -} - -.newgrid tbody tr.notice:nth-child(odd) { - background-color: #fe8; -} - -.newgrid tbody tr.notice.hovering { - background-color: #ec7; -} - -.newgrid tbody tr.warning { - background-color: #fcc; -} - -.newgrid tbody tr.warning:nth-child(odd) { - background-color: #ebb; -} - -.newgrid tbody tr.warning.hovering { - background-color: #daa; -} - -.newgrid tbody td.checkbox { - text-align: center; -} - - -/****************************** - * main actions - ******************************/ - -.newgrid .actions { - width: 1px; -} - -.newgrid .actions a { - margin: 0 5px 0 0; - position: relative; - top: -2px; -} - -.newgrid .actions a:last-child { - margin: 0; -} - -.newgrid .actions .ui-icon { - display: inline-block; - position: relative; - top: 3px; -} - - -/****************************** - * more actions - ******************************/ - -.newgrid .actions div.more { - background-color: white; - border: 1px solid black; - display: none; - padding: 3px 10px 3px 5px; - position: absolute; - z-index: 1; -} - -.newgrid .actions .more a { - display: block; - padding: 2px 0; -} 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/css/progress.css b/tailbone/static/css/progress.css new file mode 100644 index 00000000..84aab4e4 --- /dev/null +++ b/tailbone/static/css/progress.css @@ -0,0 +1,59 @@ + +/******************************************************************************** + * progress.css + * + * Styles for progress bar page. + ********************************************************************************/ + + +/****************************** + * general + ******************************/ + +#body-wrapper { + position: relative; +} + +#wrapper { + height: 60px; + left: 50%; + margin-top: -45px; + margin-left: -350px; + position: absolute; + top: 50%; + width: 700px; +} + +/****************************** + * progress bar + ******************************/ + +#progress-wrapper { + border-collapse: collapse; +} + +#progress { + border-collapse: collapse; + height: 25px; + width: 550px; +} + +#complete { + background-color: Gray; + width: 0px; +} + +#remaining { + background-color: LightGray; + width: 100%; +} + +#percentage { + padding-left: 3px; + min-width: 50px; + width: 50px; +} + +#cancel .ui-button-text { + white-space: nowrap; +} diff --git a/tailbone/static/css/purchases.css b/tailbone/static/css/purchases.css new file mode 100644 index 00000000..222c2199 --- /dev/null +++ b/tailbone/static/css/purchases.css @@ -0,0 +1,13 @@ + +/****************************** + * Styles for purchases + ******************************/ + +div.field-wrapper { + padding: 0; +} + +.field-wrapper.notes .field { + line-height: 1.1em; + white-space: pre; +} diff --git a/tailbone/static/css/jquery.ui.tailbone.css b/tailbone/static/css/schedule_print.css similarity index 52% rename from tailbone/static/css/jquery.ui.tailbone.css rename to tailbone/static/css/schedule_print.css index b6ce1023..57ca53d9 100644 --- a/tailbone/static/css/jquery.ui.tailbone.css +++ b/tailbone/static/css/schedule_print.css @@ -1,14 +1,8 @@ /********************************************************************** - * jquery.ui.tailbone.css - * - * jQuery UI tweaks for Tailbone + * styles for printing schedule **********************************************************************/ -.ui-widget { - font-size: 1em; -} - -.ui-menu-item a { - display: block; +.timesheet tbody td { + font-size: 0.8em; } diff --git a/tailbone/static/css/theme-better.css b/tailbone/static/css/theme-better.css deleted file mode 100644 index 923cc613..00000000 --- a/tailbone/static/css/theme-better.css +++ /dev/null @@ -1,120 +0,0 @@ - -/********************************************************************** - * styles for 'better' theme - **********************************************************************/ - -/**************************************** - * core overrides - ****************************************/ - -a { - color: #0972a5; -} - -.flash-messages, -.error-messages { - margin: 0.5em 0 0 0; -} - -#context-menu { - margin: 0.5em; - white-space: nowrap; -} - -.form { - padding-left: 5em; -} - -.newgrid-wrapper .grid-header #context-menu { - float: none; - margin: 0; -} - -/**************************************** - * header - ****************************************/ - -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 h1 { - border-bottom: 1px solid lightgrey; - margin: 0; - padding: 0 0 0 0.5em; -} - -/**************************************** - * content - ****************************************/ - -body > #body-wrapper { - margin: 0px; - position: relative; -} - -.content-wrapper { - height: 100%; - padding-bottom: 30px; -} - -#scrollpane { - height: 100%; -} - -#scrollpane .inner-content { - padding: 0 0.5em 0.5em 0.5em; -} - -/**************************************** - * footer - ****************************************/ - -#footer { - border-top: 1px solid lightgray; - bottom: 0; - font-size: 9pt; - height: 20px; - left: 0; - line-height: 20px; - margin: 0; - position: absolute; - width: 100%; -} diff --git a/tailbone/static/css/timesheet.css b/tailbone/static/css/timesheet.css index b5d79137..d04ece11 100644 --- a/tailbone/static/css/timesheet.css +++ b/tailbone/static/css/timesheet.css @@ -20,6 +20,10 @@ width: auto; } +.timesheet-header td.filters .buttons { + margin-bottom: 0; +} + .timesheet-header td.menu { padding: 0.5em; vertical-align: top; @@ -46,7 +50,7 @@ border-bottom: 1px solid black; border-right: 1px solid black; clear: both; - margin-top: 0.3em; + margin: 0.3em 0; width: 100%; } @@ -82,3 +86,11 @@ .timesheet tbody tr.total { font-weight: bold; } + +/****************************** + * below timesheet... + ******************************/ + +div.buttons { + margin-top: 0; +} 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/product.png b/tailbone/static/img/product.png new file mode 100644 index 00000000..08a49d73 Binary files /dev/null and b/tailbone/static/img/product.png 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 cd8f303b..00000000 --- a/tailbone/static/js/jquery.ui.tailbone.js +++ /dev/null @@ -1,341 +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('.newgrid'); - - // 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', 'thead th.sortable a', function() { - var th = $(this).parent(); - var data = { - sortkey: th.data('sortkey'), - sortdir: (th.hasClass('sorted') && th.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', function() { - $(this).addClass('hovering'); - }); - this.element.on('mouseleave', 'tbody tr', function() { - $(this).removeClass('hovering'); - }); - - // Do some extra stuff for grids with checkboxes. - if (this.grid.hasClass('selectable')) { - - // (Un-)Check all rows when clicking check-all box in header. - this.element.on('click', 'thead th.checkbox input', function() { - var checked = $(this).prop('checked'); - that.grid.find('tbody 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', 'tbody td.checkbox input', function(event) { - event.stopPropagation(); - }); - this.element.on('click', 'tbody a', function(event) { - event.stopPropagation(); - }); - this.element.on('click', 'tbody tr', 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(); - }); - }, - - // 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('.newgrid'); - 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('.newgrid-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/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 4936ed4d..00000000 --- a/tailbone/static/js/lib/jquery.ui.menubar.js +++ /dev/null @@ -1,327 +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 - .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 ec3bcd8e..00000000 --- a/tailbone/static/js/login.js +++ /dev/null @@ -1,24 +0,0 @@ - -$(function() { - - $('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/numeric.js b/tailbone/static/js/numeric.js index c7198ea7..08d86b26 100644 --- a/tailbone/static/js/numeric.js +++ b/tailbone/static/js/numeric.js @@ -30,6 +30,10 @@ function key_modifies(event) { } else if (event.which == 46) { // Delete return true; + } else if (event.ctrlKey && event.which == 86) { // Ctrl+V + return true; + } else if (event.ctrlKey && event.which == 88) { // Ctrl+X + return true; } return false; @@ -53,7 +57,9 @@ function key_allowed(event) { // Allow anything with modifiers (except Shift). if (event.altKey || event.ctrlKey || event.metaKey) { - return true; + + // ...but don't allow Ctrl+X or Ctrl+V + return event.which != 86 && event.which != 88; } // Allow function keys. @@ -71,5 +77,10 @@ function key_allowed(event) { return true; } + // allow Escape key + if (event.which == 27) { + return true; + } + return false; } diff --git a/tailbone/static/js/tailbone.batch.js b/tailbone/static/js/tailbone.batch.js deleted file mode 100644 index d45fccfc..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() { - - $('.newgrid-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.feedback.js b/tailbone/static/js/tailbone.feedback.js new file mode 100644 index 00000000..648c9695 --- /dev/null +++ b/tailbone/static/js/tailbone.feedback.js @@ -0,0 +1,55 @@ + +let FeedbackForm = { + props: ['action', 'message'], + template: '#feedback-template', + mixins: [FormPosterMixin], + methods: { + + pleaseReplyChanged(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + }, + + showFeedback() { + this.referrer = location.href + this.showDialog = true + this.$nextTick(function() { + this.$refs.textarea.focus() + }) + }, + + sendFeedback() { + + let params = { + referrer: this.referrer, + user: this.userUUID, + user_name: this.userName, + please_reply_to: this.pleaseReply ? this.userEmail : null, + message: this.message.trim(), + } + + this.submitForm(this.action, params, response => { + + this.$buefy.toast.open({ + message: "Message sent! Thank you for your feedback.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + + this.showDialog = false + // clear out message, in case they need to send another + this.message = "" + }) + }, + } +} + +let FeedbackFormData = { + referrer: null, + userUUID: null, + userName: null, + pleaseReply: false, + userEmail: null, + showDialog: false, +} diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js deleted file mode 100644 index 7e3b952c..00000000 --- a/tailbone/static/js/tailbone.js +++ /dev/null @@ -1,296 +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 form button. - */ -function disable_button(button, label) { - if (label) { - $(button).html(label + ", please wait..."); - } - $(button).attr('disabled', 'disabled'); -} - - -/* - * 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; -} - - -/* - * get_dialog(id, callback) - * - * Returns a <DIV> element suitable for use as a jQuery dialog. - * - * ``id`` is used to construct a proper ID for the element and allows the - * dialog to be resused if possible. - * - * ``callback``, if specified, should be a callback function for the dialog. - * This function will be called whenever the dialog has been closed - * "successfully" (i.e. data submitted) by the user, and should accept a single - * ``data`` object which is the JSON response returned by the server. - */ - -function get_dialog(id, callback) { - var dialog = $('#'+id+'-dialog'); - if (! dialog.length) { - dialog = $('<div class="dialog" id="'+id+'-dialog"></div>'); - } - if (callback) { - dialog.attr('callback', callback); - } - return dialog; -} - - -$(function() { - - /* - * Initialize the menu bar. - */ - $('ul.menubar').menubar({ - buttons: true, - menuIcon: true, - autoExpand: true - }); - - /* - * Fix buttons. - */ - $('button, a.button').button(); - $('input[type=submit]').button(); - $('input[type=reset]').button(); - - /* 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); - }); - - }); - - /* - * Whenever the "change" button is clicked within the context of an - * autocomplete field, hide the static display and show the autocomplete - * textbox. - */ - $('div.autocomplete-container button.autocomplete-change').click(function() { - var container = $(this).parents('div.autocomplete-container'); - var textbox = container.find('input.autocomplete-textbox'); - - container.find('input[type="hidden"]').val(''); - container.find('div.autocomplete-display').hide(); - - textbox.val(''); - textbox.show(); - textbox.select(); - textbox.focus(); - }); - - /* - * Add "check all" functionality to tables with checkboxes. - */ - $('body').on('click', '.grid thead th.checkbox input[type="checkbox"]', function() { - var table = $(this).parents('table:first'); - var checked = $(this).prop('checked'); - table.find('tbody tr').each(function() { - $(this).find('td.checkbox input[type="checkbox"]').prop('checked', checked); - }); - }); - - $('body').on('click', 'div.dialog button.close', function() { - var dialog = $(this).parents('div.dialog:first'); - dialog.dialog('close'); - }); - -}); diff --git a/tailbone/static/robots.txt b/tailbone/static/robots.txt new file mode 100644 index 00000000..1f53798b --- /dev/null +++ b/tailbone/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index c0d8a9c0..268d4818 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -1,83 +1,215 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Event Subscribers """ -from __future__ import unicode_literals, absolute_import +import datetime +import logging +import warnings +from collections import OrderedDict import rattail -from rattail import enum -from rattail.db import model -from rattail.db.auth import has_permission +import colander +import deform from pyramid import threadlocal -from pyramid.security import authenticated_userid +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['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): @@ -91,6 +223,9 @@ def add_inbox_count(event): request = event.get('request') or threadlocal.get_current_request() if request.user: renderer_globals = event + app = request.rattail_config.get_app() + model = app.model + enum = request.rattail_config.get_enum() renderer_globals['inbox_count'] = Session.query(model.Message)\ .outerjoin(model.MessageRecipient)\ .filter(model.MessageRecipient.recipient == Session.merge(request.user))\ @@ -100,51 +235,23 @@ 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``. - - * 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 = authenticated_userid(request) - if uuid: - request.user = Session.query(model.User).get(uuid) - if request.user: - Session().set_continuum_user(request.user) - - def has_perm(perm): - return has_permission(Session(), request.user, perm) - request.has_perm = has_perm - - def has_any_perm(*perms): - for perm in perms: - if has_permission(Session(), request.user, perm): - 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 + """ + return request.session.get('_timeout') + request.get_session_timeout = get_session_timeout 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 new file mode 100644 index 00000000..ef27c19c --- /dev/null +++ b/tailbone/templates/about.mako @@ -0,0 +1,19 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> +<%namespace name="base_meta" file="/base_meta.mako" /> + +<%def name="title()">About ${base_meta.app_title()}</%def> + +<%def name="page_content()"> + <h2>${project_title} ${project_version}</h2> + + % 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> +</%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 341ad8bb..4c413757 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -1,53 +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, 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() { - $('#${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 77b7271f..8228f823 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,161 +1,1017 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- +<%namespace file="/wutta-components.mako" import="make_wutta_components" /> +<%namespace file="/grids/nav.mako" import="grid_index_nav" /> +<%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> +<%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> +<%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 style="direction: ltr;" xmlns="http://www.w3.org/1999/xhtml" lang="en-us"> +<html lang="en"> <head> <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> - <title>${self.global_title()} » ${capture(self.title)}</title> - <link rel="icon" type="image/x-icon" href="${request.static_url('tailbone:static/img/rattail.ico')}" /> - ${self.core_javascript()} - ${self.core_styles()} + <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, .navbar, .footer { + background-image: url(${request.static_url('tailbone:static/img/testing.png')}); + } + </style> + % endif + ${self.head_tags()} </head> <body> - - <div id="body-wrapper"> - - <div id="header"> - <h1>${h.link_to(capture(self.global_title), url('home'))}</h1> - <h1 class="title">» ${self.title()}</h1> - <div class="login"> - % if request.user: - ${h.link_to(request.user.display_name, url('change_password'))} - (${h.link_to("logout", url('logout'))}) - % else: - ${h.link_to("login", url('login'))} - % endif - </div> - </div><!-- header --> - - <ul class="menubar"> - <li> - <a>Products</a> - <ul> - <li>${h.link_to("Products", url('products'))}</li> - <li>${h.link_to("Brands", url('brands'))}</li> - </ul> - </li> - <li> - <a>Customers</a> - <ul> - <li>${h.link_to("Customers", url('customers'))}</li> - <li>${h.link_to("Customer Groups", url('customer_groups'))}</li> - </ul> - </li> - <li> - <a>Employees</a> - <ul> - <li>${h.link_to("Employees", url('employees'))}</li> - </ul> - </li> - <li> - <a>Vendors</a> - <ul> - <li>${h.link_to("Vendors", url('vendors'))}</li> - </ul> - </li> - % if request.has_perm('batches.list'): - <li> - <a>Batches</a> - <ul> - <li>${h.link_to("Batches", url('batches'))}</li> - </ul> - </li> - % endif - <li> - <a>Stores</a> - <ul> - <li>${h.link_to("Stores", url('stores'))}</li> - <li>${h.link_to("Departments", url('departments'))}</li> - <li>${h.link_to("Subdepartments", url('subdepartments'))}</li> - </ul> - </li> - % if request.has_perm('users.list') or request.has_perm('roles.list'): - <li> - <a>Auth</a> - <ul> - % if request.has_perm('users.list'): - <li>${h.link_to("Users", url('users'))}</li> - % endif - % if request.has_perm('roles.list'): - <li>${h.link_to("Roles", url('roles'))}</li> - % endif - </ul> - </li> - % endif - </ul> - - <div id="body"> - - % 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><!-- body --> - - </div><!-- body-wrapper --> - - <div id="footer"> - powered by ${h.link_to("Rattail", 'http://rattailproject.org/', target='_blank')} + <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="global_title()">Tailbone</%def> - <%def name="title()"></%def> +<%def name="content_title()"> + ${self.title()} +</%def> + +<%def name="header_core()"> + + ${self.core_javascript()} + ${self.extra_javascript()} + ${self.core_styles()} + ${self.extra_styles()} + + ## TODO: should 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.11.3.min.js')} - ${h.javascript_link('https://code.jquery.com/ui/1.11.4/jquery-ui.min.js')} - ${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'))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.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"> + + ## 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="core_styles(jquery_theme=None)"> - ${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))} - % if jquery_theme: - ${jquery_theme()} +<%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()"> + + ${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="buefy_styles()"> + % if user_css: + ${h.stylesheet_link(user_css)} % else: - ${self.jquery_smoothness_theme()} + ## upstream Buefy CSS + ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))} % endif - ${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/newgrids.css'))} </%def> -<%def name="jquery_smoothness_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/smoothness/jquery-ui.min.css')} +<%def name="extra_styles()"> + ${base_meta.extra_styles()} </%def> <%def name="head_tags()"></%def> + +<%def name="render_vue_template_whole_page()"> + <script type="text/x-template" id="whole-page-template"> + <div> + <header> + + <nav class="navbar" role="navigation" aria-label="main navigation"> + + <div class="navbar-brand"> + <a class="navbar-item" href="${url('home')}" + v-show="!globalSearchActive"> + ${base_meta.header_logo()} + <div id="global-header-title"> + ${base_meta.global_title()} + </div> + </a> + <div v-show="globalSearchActive" + class="navbar-item"> + <b-autocomplete ref="globalSearchAutocomplete" + v-model="globalSearchTerm" + :data="globalSearchFilteredData" + field="label" + open-on-focus + keep-first + icon-pack="fas" + clearable + @keydown.native="globalSearchKeydown" + @select="globalSearchSelect"> + </b-autocomplete> + </div> + <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false"> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> + + <div class="navbar-menu" id="navbar-menu"> + <div class="navbar-start"> + + <div v-if="globalSearchData.length" + class="navbar-item"> + <b-button type="is-primary" + size="is-small" + @click="globalSearchInit()"> + <span><i class="fa fa-search"></i></span> + </b-button> + </div> + + % for topitem in menus: + % if topitem['is_link']: + ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} + % else: + <div class="navbar-item has-dropdown is-hoverable"> + <a class="navbar-link">${topitem['title']}</a> + <div class="navbar-dropdown"> + % for item in topitem['items']: + % if item['is_menu']: + <% item_hash = id(item) %> + <% toggle = 'menu_{}_shown'.format(item_hash) %> + <div> + <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> + ${item['title']} + </a> + </div> + % for subitem in item['items']: + % if subitem['is_sep']: + <hr class="navbar-divider" v-show="${toggle}"> + % else: + ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} + % endif + % endfor + % else: + % if item['is_sep']: + <hr class="navbar-divider"> + % else: + ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} + % endif + % endif + % endfor + </div> + </div> + % endif + % endfor + + </div><!-- navbar-start --> + ${self.render_navbar_end()} + </div> + </nav> + + <nav class="level" style="margin: 0.5rem auto;"> + <div class="level-left"> + + ## Current Context + <div id="current-context" class="level-item"> + % if master: + % if master.listing: + <span class="header-text"> + ${index_title} + </span> + % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % elif index_url: + <span class="header-text"> + ${h.link_to(index_title, index_url)} + </span> + % if parent_url is not Undefined: + <span class="header-text"> + » + </span> + <span class="header-text"> + ${h.link_to(parent_title, parent_url)} + </span> + % elif instance_url is not Undefined: + <span class="header-text"> + » + </span> + <span class="header-text"> + ${h.link_to(instance_title, instance_url)} + </span> + % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + % if not request.matched_route.name.endswith('.create'): + <once-button type="is-primary" + tag="a" href="${url('{}.create'.format(route_prefix))}" + icon-left="plus" + style="margin-left: 1rem;" + text="Create New"> + </once-button> + % endif + % endif + % if master.viewing and grid_index: + ${grid_index_nav()} + % endif + % else: + <span class="header-text"> + ${index_title} + </span> + % endif + % elif index_title: + % if index_url: + <span class="header-text"> + ${h.link_to(index_title, index_url)} + </span> + % else: + <span class="header-text"> + ${index_title} + </span> + % endif + % endif + </div> + + % if expose_db_picker is not Undefined and expose_db_picker: + <div class="level-item"> + <p>DB:</p> + </div> + <div class="level-item"> + ${h.form(url('change_db_engine'), ref='dbPickerForm')} + ${h.csrf_token(request)} + ${h.hidden('engine_type', value=master.engine_type_key)} + <b-select name="dbkey" + value="${db_picker_selected}" + @input="changeDB()"> + % for option in db_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + ${h.end_form()} + </div> + % endif + + </div><!-- level-left --> + <div class="level-right"> + + ## Quickie Lookup + % if quickie is not Undefined and quickie and request.has_perm(quickie.perm): + <div class="level-item"> + ${h.form(quickie.url, method="get")} + <div class="level"> + <div class="level-right"> + <div class="level-item"> + <b-input name="entry" + placeholder="${quickie.placeholder}" + autocomplete="off"> + </b-input> + </div> + <div class="level-item"> + <button type="submit" class="button is-primary"> + <span class="icon is-small"> + <i class="fas fa-search"></i> + </span> + <span>Lookup</span> + </button> + </div> + </div> + </div> + ${h.end_form()} + </div> + % endif + + % if master and master.configurable and master.has_perm('configure'): + % if not request.matched_route.name.endswith('.configure'): + <div class="level-item"> + <once-button type="is-primary" + tag="a" + href="${url('{}.configure'.format(route_prefix))}" + icon-left="cog" + text="${(configure_button_title or "Configure") if configure_button_title is not Undefined else "Configure"}"> + </once-button> + </div> + % endif + % endif + + ## Theme Picker + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + <div class="level-item"> + ${h.form(url('change_theme'), method="post", ref='themePickerForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" :value="referrer" /> + <div style="display: flex; align-items: center; gap: 0.5rem;"> + <span>Theme:</span> + <b-select name="theme" + v-model="globalTheme" + @input="changeTheme()"> + % for option in theme_picker_options: + <option value="${option.value}"> + ${option.label} + </option> + % endfor + </b-select> + </div> + ${h.end_form()} + </div> + % endif + + <div class="level-item"> + <page-help + % if can_edit_help: + @configure-fields-help="configureFieldsHelp = true" + % endif + > + </page-help> + </div> + + ## Feedback Button / Dialog + % if request.has_perm('common.feedback'): + <feedback-form + action="${url('feedback')}" + :message="feedbackMessage"> + </feedback-form> + % endif + + </div><!-- level-right --> + </nav><!-- level --> + </header> + + ## Page Title + % if capture(self.content_title): + <section id="content-title" + class="has-background-primary"> + <div style="display: flex; align-items: center; padding: 0.5rem;"> + + <h1 class="title has-text-white" + v-html="contentTitleHTML"> + </h1> + + <div style="flex-grow: 1; display: flex; gap: 0.5rem;"> + ${self.render_instance_header_title_extras()} + </div> + + <div style="display: flex; gap: 0.5rem;"> + ${self.render_instance_header_buttons()} + </div> + + </div> + </section> + % endif + + <div class="content-wrapper"> + + ## Page Body + <section id="page-body"> + + % if request.session.peek_flash('error'): + % for error in request.session.pop_flash('error'): + <b-notification type="is-warning"> + ${error} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash('warning'): + % for msg in request.session.pop_flash('warning'): + <b-notification type="is-warning"> + ${msg} + </b-notification> + % endfor + % endif + + % if request.session.peek_flash(): + % for msg in request.session.pop_flash(): + <b-notification type="is-info"> + ${msg} + </b-notification> + % endfor + % endif + + ${self.render_this_page_component()} + </section> + + ## Footer + <footer class="footer"> + <div class="content"> + ${base_meta.footer()} + </div> + </footer> + + </div><!-- content-wrapper --> + </div> + </script> + + ${page_help.render_template()} + + % if request.has_perm('common.feedback'): + <script type="text/x-template" id="feedback-template"> + <div> + + <div class="level-item"> + <b-button type="is-primary" + @click="showFeedback()" + icon-pack="fas" + icon-left="comment"> + Feedback + </b-button> + </div> + + <b-modal has-modal-card + :active.sync="showDialog"> + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">User Feedback</p> + </header> + + <section class="modal-card-body"> + <p class="block"> + Questions, suggestions, comments, complaints, etc. + <span class="red">regarding this website</span> are + welcome and may be submitted below. + </p> + + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + > + </b-input> + </b-field> + + <b-field label="Referring URL"> + <b-input + v-model="referrer" + disabled="true"> + </b-input> + </b-field> + + <b-field label="Message"> + <b-input type="textarea" + v-model="message" + ref="textarea"> + </b-input> + </b-field> + + % if request.rattail_config.getbool('tailbone', 'feedback_allows_reply'): + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <b-checkbox v-model="pleaseReply" + @input="pleaseReplyChanged"> + Please email me back{{ pleaseReply ? " at: " : "" }} + </b-checkbox> + </div> + <div class="level-item" v-show="pleaseReply"> + <b-input v-model="userEmail" + ref="userEmail"> + </b-input> + </div> + </div> + </div> + % endif + + </section> + + <footer class="modal-card-foot"> + <b-button @click="showDialog = false"> + Cancel + </b-button> + <once-button type="is-primary" + @click="sendFeedback()" + :disabled="!message.trim()" + text="Send Message"> + </once-button> + </footer> + </div> + </b-modal> + + </div> + </script> + % endif + + ${tailbone_autocomplete_template()} + ${multi_file_upload.render_template()} +</%def> + +<%def name="render_this_page_component()"> + <this-page @change-content-title="changeContentTitle" + % if can_edit_help: + :configure-fields-help="configureFieldsHelp" + % endif + > + </this-page> +</%def> + +<%def name="render_navbar_end()"> + <div class="navbar-end"> + ${self.render_user_menu()} + </div> +</%def> + +<%def name="render_user_menu()"> + % if request.user: + <div class="navbar-item has-dropdown is-hoverable"> + % if messaging_enabled: + <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}${" ({})".format(inbox_count) if inbox_count else ''}</a> + % else: + <a class="navbar-link ${'root-user' if request.is_root else ''}">${request.user}</a> + % endif + <div class="navbar-dropdown"> + % if request.is_root: + ${h.form(url('stop_root'), ref='stopBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.stopBeingRootForm.submit()" + class="navbar-item root-user"> + Stop being root + </a> + ${h.end_form()} + % elif request.is_admin: + ${h.form(url('become_root'), ref='startBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.startBeingRootForm.submit()" + class="navbar-item root-user"> + Become root + </a> + ${h.end_form()} + % endif + % if messaging_enabled: + ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} + % endif + % if request.is_root or not request.user.prevent_password_change: + ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} + % endif + % try: + ## nb. does not exist yet for wuttaweb + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + % except: + % endtry + ${h.link_to("Logout", url('logout'), class_='navbar-item')} + </div> + </div> + % else: + ${h.link_to("Login", url('login'), class_='navbar-item')} + % endif +</%def> + +<%def name="render_instance_header_title_extras()"></%def> + +<%def name="render_instance_header_buttons()"> + ${self.render_crud_header_buttons()} + ${self.render_prevnext_header_buttons()} +</%def> + +<%def name="render_crud_header_buttons()"> + % if master and master.viewing: + ## TODO: is there a better way to check if viewing parent? + % if parent_instance is Undefined: + % if master.editable and instance_editable and master.has_perm('edit'): + <once-button tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + % endif + % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'): + <once-button tag="a" href="${master.get_action_url('clone', instance)}" + icon-left="object-ungroup" + text="Clone This"> + </once-button> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % else: + ## viewing row + % if instance_deletable and master.has_perm('delete_row'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % endif + % elif master and master.editing: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % if master.deletable and instance_deletable and master.has_perm('delete'): + <once-button tag="a" href="${master.get_action_url('delete', instance)}" + type="is-danger" + icon-left="trash" + text="Delete This"> + </once-button> + % endif + % elif master and master.deleting: + % if master.viewable and master.has_perm('view'): + <once-button tag="a" href="${master.get_action_url('view', instance)}" + icon-left="eye" + text="View This"> + </once-button> + % endif + % if master.editable and instance_editable and master.has_perm('edit'): + <once-button tag="a" href="${master.get_action_url('edit', instance)}" + icon-left="edit" + text="Edit This"> + </once-button> + % endif + % endif +</%def> + +<%def name="render_prevnext_header_buttons()"> + % if show_prev_next is not Undefined and show_prev_next: + % if prev_url: + <b-button tag="a" href="${prev_url}" + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-left"> + Older + </b-button> + % endif + % if next_url: + <b-button tag="a" href="${next_url}" + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % else: + <b-button tag="a" href="#" + disabled + icon-pack="fas" + icon-left="arrow-right"> + Newer + </b-button> + % endif + % endif +</%def> + +<%def name="render_vue_script_whole_page()"> + <script> + + let WholePage = { + template: '#whole-page-template', + mixins: [FormPosterMixin], + computed: { + + globalSearchFilteredData() { + if (!this.globalSearchTerm.length) { + return this.globalSearchData + } + + let terms = [] + for (let term of this.globalSearchTerm.toLowerCase().split(' ')) { + term = term.trim() + if (term) { + terms.push(term) + } + } + if (!terms.length) { + return this.globalSearchData + } + + // all terms must match + return this.globalSearchData.filter((option) => { + let label = option.label.toLowerCase() + for (let term of terms) { + if (label.indexOf(term) < 0) { + return false + } + } + return true + }) + }, + + }, + + mounted() { + window.addEventListener('keydown', this.globalKey) + for (let hook of this.mountedHooks) { + hook(this) + } + }, + beforeDestroy() { + window.removeEventListener('keydown', this.globalKey) + }, + + methods: { + + changeContentTitle(newTitle) { + this.contentTitleHTML = newTitle + }, + + % if expose_db_picker is not Undefined and expose_db_picker: + changeDB() { + this.$refs.dbPickerForm.submit() + }, + % endif + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + changeTheme() { + this.$refs.themePickerForm.submit() + }, + % endif + + globalKey(event) { + + // Ctrl+8 opens global search + if (event.target.tagName == 'BODY') { + if (event.ctrlKey && event.key == '8') { + this.globalSearchInit() + } + } + }, + + globalSearchInit() { + this.globalSearchTerm = '' + this.globalSearchActive = true + this.$nextTick(() => { + this.$refs.globalSearchAutocomplete.focus() + }) + }, + + globalSearchKeydown(event) { + + // ESC will dismiss searchbox + if (event.which == 27) { + this.globalSearchActive = false + } + }, + + globalSearchSelect(option) { + location.href = option.url + }, + + toggleNestedMenu(hash) { + const key = 'menu_' + hash + '_shown' + this[key] = !this[key] + }, + }, + } + + let WholePageData = { + contentTitleHTML: ${json.dumps(capture(self.content_title))|n}, + feedbackMessage: "", + + % if expose_theme_picker and request.has_perm('common.change_app_theme'): + globalTheme: ${json.dumps(theme or None)|n}, + referrer: location.href, + % endif + + % if can_edit_help: + configureFieldsHelp: false, + % endif + + globalSearchActive: false, + globalSearchTerm: '', + globalSearchData: ${json.dumps(global_search_data or [])|n}, + + mountedHooks: [], + } + + ## declare nested menu visibility toggle flags + % for topitem in menus: + % if topitem['is_menu']: + % for item in topitem['items']: + % if item['is_menu']: + WholePageData.menu_${id(item)}_shown = false + % endif + % endfor + % endif + % endfor + + </script> +</%def> + +<%def name="wtfield(form, name, **kwargs)"> + <div class="field-wrapper${' error' if form[name].errors else ''}"> + <label for="${name}">${form[name].label}</label> + <div class="field"> + ${form[name](**kwargs)} + </div> + </div> +</%def> + +<%def name="simple_field(label, value)"> + ## TODO: keep this? only used by personal profile view currently + ## (although could be useful for any readonly scenario) + <div class="field-wrapper"> + <div class="field-row"> + <label>${label}</label> + <div class="field"> + ${'' if value is None else value} + </div> + </div> + </div> +</%def> + +############################## +## 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/create.mako b/tailbone/templates/batch/create.mako index bb7c5cfe..fe2b3d07 100644 --- a/tailbone/templates/batch/create.mako +++ b/tailbone/templates/batch/create.mako @@ -1,12 +1,4 @@ -## -*- coding: utf-8 -*- -<%inherit file="/crud.mako" /> - -<%def name="title()">New ${batch_display}</%def> - -<%def name="context_menu_items()"> - % if request.has_perm('{0}.view'.format(permission_prefix)): - <li>${h.link_to("Back to {0}".format(batch_display_plural), url(route_prefix))}</li> - % endif -</%def> +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> ${parent.body()} diff --git a/tailbone/templates/batch/crud.mako b/tailbone/templates/batch/crud.mako deleted file mode 100644 index ecc61021..00000000 --- a/tailbone/templates/batch/crud.mako +++ /dev/null @@ -1,82 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/crud.mako" /> - -<%def name="title()">${"View" if form.readonly else "Edit"} ${batch_display}</%def> - -<%def name="head_tags()"> - <script type="text/javascript"> - $(function() { - $('#rows-wrapper').load('${url('{0}.rows'.format(route_prefix), uuid=batch.uuid)}', function() { - // TODO: It'd be nice if we didn't have to do this here. - $(this).find('button').button(); - $(this).find('input[type=submit]').button(); - }); - $('#save-refresh').click(function() { - $('#batch-form').append($('<input type="hidden" name="refresh" value="true" />')); - $('#batch-form').submit(); - }); - }); - </script> - <style type="text/css"> - #rows-wrapper { - margin-top: 10px; - } - .grid tr.notice.odd { - background-color: #fe8; - } - .grid tr.notice.even { - background-color: #fd6; - } - .grid tr.notice.hovering { - background-color: #ec7; - } - .grid tr.warning.odd { - background-color: #ebb; - } - .grid tr.warning.even { - background-color: #fcc; - } - .grid tr.warning.hovering { - background-color: #daa; - } - </style> -</%def> - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to {0}".format(batch_display_plural), url(route_prefix))}</li> - % if not batch.executed: - % if form.updating: - <li>${h.link_to("View this {0}".format(batch_display), url('{0}.view'.format(route_prefix), uuid=batch.uuid))}</li> - % endif - % if form.readonly and request.has_perm('{0}.edit'.format(permission_prefix)): - <li>${h.link_to("Edit this {0}".format(batch_display), url('{0}.edit'.format(route_prefix), uuid=batch.uuid))}</li> - % endif - % endif - % if request.has_perm('{0}.delete'.format(permission_prefix)): - <li>${h.link_to("Delete this {0}".format(batch_display), url('{0}.delete'.format(route_prefix), uuid=batch.uuid))}</li> - % endif -</%def> - -<div class="form-wrapper"> - - <ul class="context-menu"> - ${self.context_menu_items()} - </ul> - - ${form.render(form_id='batch-form', buttons=capture(buttons))|n} - -</div> - -<%def name="buttons()"> - <div class="buttons"> - % if not form.readonly and batch.refreshable: - ${h.submit('save-refresh', "Save & Refresh Data")} - % endif - % if not batch.executed and request.has_perm('{0}.execute'.format(permission_prefix)): - ## ${h.link_to(execute_title, url('{0}.execute'.format(route_prefix), uuid=batch.uuid))} - <button type="button" onclick="location.href = '${url('{0}.execute'.format(route_prefix), uuid=batch.uuid)}';"${'' if execute_enabled else ' disabled="disabled"'}">${execute_title}</button> - % endif - </div> -</%def> - -<div id="rows-wrapper"></div> diff --git a/tailbone/templates/batch/edit.mako b/tailbone/templates/batch/edit.mako index d6922b7c..4d9e990e 100644 --- a/tailbone/templates/batch/edit.mako +++ b/tailbone/templates/batch/edit.mako @@ -1,3 +1,3 @@ -## -*- coding: utf-8 -*- -<%inherit file="/batch/crud.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/master/edit.mako" /> ${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/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 f7a6550a..bea10a97 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -1,12 +1,133 @@ -## -*- coding: utf-8 -*- -<%inherit file="/grid.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> -<%def name="title()">${batch_display_plural}</%def> +<%def name="grid_tools()"> + ${parent.grid_tools()} -<%def name="context_menu_items()"> - % if request.has_perm('{0}.create'.format(permission_prefix)): - <li>${h.link_to("Create a new {0}".format(batch_display), url('{0}.create'.format(route_prefix)))}</li> + ## 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> -${parent.body()} +<%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/row.view.mako b/tailbone/templates/batch/row.view.mako deleted file mode 100644 index 7fc33199..00000000 --- a/tailbone/templates/batch/row.view.mako +++ /dev/null @@ -1,10 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/crud.mako" /> - -<%def name="title()">${batch_display} Row</%def> - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to {0}".format(batch_display), url('{0}.view'.format(route_prefix), uuid=row.batch_uuid))}</li> -</%def> - -${parent.body()} diff --git a/tailbone/templates/batch/rows.mako b/tailbone/templates/batch/rows.mako deleted file mode 100644 index 616fc694..00000000 --- a/tailbone/templates/batch/rows.mako +++ /dev/null @@ -1,22 +0,0 @@ -## -*- coding: utf-8 -*- -<div class="grid-wrapper"> - - <table class="grid-header"> - <tr> - <td rowspan="2" class="form"> - ${search.render()} - </td> - </tr> - <tr> - <td class="tools"> - ## TODO: Fix this check for edit mode. - % if not batch.executed and request.referrer.endswith('/edit'): - <p>${h.link_to("Delete all rows matching current search", url('{0}.rows.bulk_delete'.format(route_prefix), uuid=batch.uuid))}</p> - % endif - </td> - </tr> - </table> - - ${grid} - -</div> 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 74cb10d3..7c81ab0e 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -1,34 +1,351 @@ -## -*- coding: utf-8 -*- -<%inherit file="/batch/crud.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> -<%def name="head_tags()"> - ${parent.head_tags()} - <script type="text/javascript"> - $(function() { - $('#execute-batch').click(function() { - $(this).button('option', 'label', "Executing, please wait...").button('disable'); - location.href = '${url('{0}.execute'.format(route_prefix), uuid=batch.uuid)}'; - }); - }); - </script> -</%def> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('{0}.csv'.format(permission_prefix)): - <li>${h.link_to("Download this {0} as CSV".format(batch_display), url('{0}.csv'.format(route_prefix), uuid=batch.uuid))}</li> - % endif + .modal-card-body label { + white-space: nowrap; + } + + .markdown p { + margin-bottom: 1.5rem; + } + + </style> </%def> <%def name="buttons()"> <div class="buttons"> - % if not form.readonly and batch.refreshable: - ${h.submit('save-refresh', "Save & Refresh Data")} - % endif - % if not batch.executed and request.has_perm('{0}.execute'.format(permission_prefix)): - <button type="button" id="execute-batch"${'' if execute_enabled else ' disabled="disabled"'}>${execute_title}</button> - % endif + ${self.leading_buttons()} + ${refresh_button()} + ${self.trailing_buttons()} </div> </%def> -${parent.body()} +<%def name="leading_buttons()"> + % 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.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="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> + +<%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> + </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 new file mode 100644 index 00000000..a0dca748 --- /dev/null +++ b/tailbone/templates/batch/worksheet.mako @@ -0,0 +1,31 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + .worksheet tr.active { + border: 5px solid Blue; + } + + .worksheet .current-entry { + text-align: center; + } + + .worksheet .current-entry input { + text-align: center; + width: 3em; + } + + </style> +</%def> + +<%def name="worksheet_grid()"></%def> + +<%def name="page_content()"> + ${self.worksheet_grid()} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/batches/crud.mako b/tailbone/templates/batches/crud.mako deleted file mode 100644 index 56e40bde..00000000 --- a/tailbone/templates/batches/crud.mako +++ /dev/null @@ -1,12 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to Batches", url('batches'))}</li> - <li>${h.link_to("View Batch Rows", url('batch.rows', uuid=form.fieldset.model.uuid))}</li> - % if not form.readonly: - <li>${h.link_to("View this Batch", url('batch.read', uuid=form.fieldset.model.uuid))}</li> - % endif -</%def> - -${parent.body()} diff --git a/tailbone/templates/batches/index.mako b/tailbone/templates/batches/index.mako deleted file mode 100644 index 1ce1b925..00000000 --- a/tailbone/templates/batches/index.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/grid.mako" /> - -<%def name="title()">Batches</%def> - -${parent.body()} diff --git a/tailbone/templates/batches/params.mako b/tailbone/templates/batches/params.mako deleted file mode 100644 index 34b75022..00000000 --- a/tailbone/templates/batches/params.mako +++ /dev/null @@ -1,43 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> - -<%def name="title()">Batch Parameters</%def> - -<%def name="head_tags()"> - ${parent.head_tags()} - <script language="javascript" type="text/javascript"> - - $(function() { - - $('#create-batch').click(function() { - disable_button(this, "Creating batch"); - disable_button('#cancel'); - $('form').submit(); - }); - - }); - - </script> -</%def> - -<%def name="batch_params()"></%def> - -<p>Please provide the following values for your new batch:</p> -<br /> - -<div class="form"> - - ${h.form(request.get_referrer())} - ${h.hidden('provider', value=provider)} - ${h.hidden('params', value='True')} - - ${self.batch_params()} - - <div class="buttons"> - <button type="button" id="create-batch">Create Batch</button> - <button type="button" id="cancel" onclick="location.href = '${request.get_referrer()}';">Cancel</button> - </div> - - ${h.end_form()} - -</div> diff --git a/tailbone/templates/batches/params/print_labels.mako b/tailbone/templates/batches/params/print_labels.mako deleted file mode 100644 index 3ad7b8d0..00000000 --- a/tailbone/templates/batches/params/print_labels.mako +++ /dev/null @@ -1,20 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/batches/params.mako" /> - -<%def name="batch_params()"> - - <div class="field-wrapper"> - <label for="profile">Label Type</label> - <div class="field"> - ${h.select('profile', None, label_profiles)} - </div> - </div> - - <div class="field-wrapper"> - <label for="quantity">Quantity</label> - <div class="field">${h.text('quantity', value=1)}</div> - </div> - -</%def> - -${parent.body()} diff --git a/tailbone/templates/batches/read.mako b/tailbone/templates/batches/read.mako deleted file mode 100644 index 5d1ce152..00000000 --- a/tailbone/templates/batches/read.mako +++ /dev/null @@ -1,41 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/batches/crud.mako" /> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - <li>${h.link_to("Edit this Batch", url('batch.update', uuid=form.fieldset.model.uuid))}</li> - <li>${h.link_to("Delete this Batch", url('batch.delete', uuid=form.fieldset.model.uuid))}</li> -</%def> - -${parent.body()} - -<% batch = form.fieldset.model %> - -<h2>Columns</h2> - -<div class="grid full hoverable"> - <table> - <thead> - <tr> - <th>Name</th> - <th>SIL Name</th> - <th>Display Name</th> - <th>Description</th> - <th>Data Type</th> - <th>Visible</th> - </tr> - </thead> - <tbody> - % for i, column in enumerate(batch.columns, 1): - <tr class="${'odd' if i % 2 else 'even'}"> - <td>${column.name}</td> - <td>${column.sil_name}</td> - <td>${column.display_name}</td> - <td>${column.description}</td> - <td>${column.data_type}</td> - <td>${column.visible}</td> - </tr> - % endfor - </tbody> - </table> -</div> diff --git a/tailbone/templates/batches/rows/crud.mako b/tailbone/templates/batches/rows/crud.mako deleted file mode 100644 index 27ffd848..00000000 --- a/tailbone/templates/batches/rows/crud.mako +++ /dev/null @@ -1,9 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to Batch", url('batch.read', uuid=form.fieldset.model.batch.uuid))}</li> - <li>${h.link_to("Back to Batch Rows", url('batch.rows', uuid=form.fieldset.model.batch.uuid))}</li> -</%def> - -${parent.body()} diff --git a/tailbone/templates/batches/rows/index.mako b/tailbone/templates/batches/rows/index.mako deleted file mode 100644 index dbe2ee0f..00000000 --- a/tailbone/templates/batches/rows/index.mako +++ /dev/null @@ -1,47 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/grid.mako" /> - -<%def name="title()">Batch Rows : ${batch.description}</%def> - -<%def name="head_tags()"> - ${parent.head_tags()} - <script language="javascript" type="text/javascript"> - - $(function() { - - $('#delete-results').click(function() { - var msg = "This will delete all rows matching the current search.\n\n" - + "PLEASE NOTE that this may include some rows which are not visible " - + "on your screen.\n(I.e., if there is more than one \"page\" of results.)\n\n" - + "Are you sure you wish to delete these rows?"; - if (confirm(msg)) { - disable_button(this, "Deleting rows"); - location.href = '${url('batch.rows.delete', uuid=batch.uuid)}'; - } - }); - - $('#execute-batch').click(function() { - if (confirm("Are you sure you wish to execute this batch?")) { - disable_button(this, "Executing batch"); - location.href = '${url('batch.execute', uuid=batch.uuid)}'; - } - }); - - }); - - </script> -</%def> - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to Batches", url('batches'))}</li> - <li>${h.link_to("Back to Batch", url('batch.read', uuid=batch.uuid))}</li> -</%def> - -<%def name="tools()"> - <div class="buttons"> - <button type="button" id="delete-results">Delete Results</button> - <button type="button" id="execute-batch">Execute Batch</button> - </div> -</%def> - -${parent.body()} diff --git a/tailbone/templates/brands/versions/index.mako b/tailbone/templates/brands/versions/index.mako deleted file mode 100644 index d26bebd4..00000000 --- a/tailbone/templates/brands/versions/index.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/index.mako" /> -${parent.body()} diff --git a/tailbone/templates/brands/versions/view.mako b/tailbone/templates/brands/versions/view.mako deleted file mode 100644 index 94567acb..00000000 --- a/tailbone/templates/brands/versions/view.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/view.mako" /> -${parent.body()} diff --git a/tailbone/templates/categories/versions/index.mako b/tailbone/templates/categories/versions/index.mako deleted file mode 100644 index d26bebd4..00000000 --- a/tailbone/templates/categories/versions/index.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/index.mako" /> -${parent.body()} diff --git a/tailbone/templates/categories/versions/view.mako b/tailbone/templates/categories/versions/view.mako deleted file mode 100644 index 94567acb..00000000 --- a/tailbone/templates/categories/versions/view.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/view.mako" /> -${parent.body()} diff --git a/tailbone/templates/change_password.mako b/tailbone/templates/change_password.mako index 6b1ab948..c64acebf 100644 --- a/tailbone/templates/change_password.mako +++ b/tailbone/templates/change_password.mako @@ -1,16 +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.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 index 1234e4f8..490e4757 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -1,50 +1,38 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%namespace file="/util.mako" import="view_profiles_helper" /> -${parent.body()} +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if show_profiles_helper and show_profiles_people: + ${view_profiles_helper(show_profiles_people)} + % endif +</%def> -<h2>People</h2> -% if instance.people: - <p>Customer account is associated with the following people:</p> - <div class="grid clickable"> - <table> - <thead> - <th>First Name</th> - <th>Last Name</th> - </thead> - <tbody> - % for i, person in enumerate(instance.people, 1): - <tr class="${'odd' if i % 2 else 'even'}" url="${url('people.view', uuid=person.uuid)}"> - <td>${person.first_name or ''}</td> - <td>${person.last_name or ''}</td> - </tr> - % endfor - </tbody> - </table> - </div> -% else: - <p>Customer account is not associated with any people.</p> -% endif +<%def name="render_form()"> + <div class="form"> + <tailbone-form @detach-person="detachPerson"> + </tailbone-form> + </div> +</%def> -<h2>Groups</h2> -% if instance.groups: - <p>Customer account belongs to the following groups:</p> - <div class="grid clickable"> - <table> - <thead> - <th>ID</th> - <th>Name</th> - </thead> - <tbody> - % for i, group in enumerate(instance.groups, 1): - <tr class="${'odd' if i % 2 else 'even'}" url="${url('customergroups.view', uuid=group.uuid)}"> - <td>${group.id}</td> - <td>${group.name or ''}</td> - </tr> - % endfor - </tbody> - </table> - </div> -% else: - <p>Customer account doesn't belong to any groups.</p> -% endif +<%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 dd143f85..86f5c121 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -1,25 +1,52 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="head_tags()"> - ${parent.head_tags()} - <script type="text/javascript"> - $(function() { +<%def name="grid_tools()"> + ${parent.grid_tools()} - $('form[name="restart-datasync"]').submit(function() { - $(this).find('button') - .button('option', 'label', "Restarting DataSync...") - .button('disable'); - }); + % 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> + +<%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> - -<%def name="grid_tools()"> - ${h.form(url('datasync.restart'), name='restart-datasync')} - <button type="submit">Restart DataSync</button> - ${h.end_form()} -</%def> - -${parent.body()} diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako new file mode 100644 index 00000000..2e444fb5 --- /dev/null +++ b/tailbone/templates/datasync/configure.mako @@ -0,0 +1,989 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style> + .invisible-watcher { + display: none; + } + </style> +</%def> + +<%def name="buttons_row()"> + <div class="level"> + <div class="level-left"> + + <div class="level-item"> + <p class="block"> + This tool lets you modify the DataSync configuration. + Before using it, + <a href="#" class="has-background-warning" + @click.prevent="showConfigFilesNote = !showConfigFilesNote"> + please see these notes. + </a> + </p> + </div> + + <div class="level-item"> + ${self.save_undo_buttons()} + </div> + </div> + + <div class="level-right"> + + <div class="level-item"> + ${h.form(url('datasync.restart'), **{'@submit': 'submitRestartDatasyncForm'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + @click="restartDatasync" + :disabled="restartingDatasync" + icon-pack="fas" + icon-left="redo"> + {{ restartDatasyncFormButtonText }} + </b-button> + ${h.end_form()} + </div> + + <div class="level-item"> + ${self.purge_button()} + </div> + </div> + </div> +</%def> + +<%def name="form_content()"> + ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})} + + <b-notification type="is-warning" + % if request.use_oruga: + v-model:active="showConfigFilesNote" + % else: + :active.sync="showConfigFilesNote" + % endif + > + ## TODO: should link to some ratman page here, yes? + <p class="block"> + This tool works by modifying settings in the DB. It + does <span class="is-italic">not</span> modify any config + files. If you intend to manage datasync watcher/consumer + config via files only then you should be sure to UNCHECK the + "Use these Settings.." checkbox near the top of page. + </p> + <p class="block"> + If you have managed config via files thus far, and want to + start using this tool to manage via DB settings instead, + that's fine - but after saving the settings via this tool + you should probably remove all + <span class="is-family-code">[rattail.datasync]</span> entries + from your config file (and restart apps) so as to avoid + confusion. + </p> + </b-notification> + + <b-field> + <b-checkbox name="rattail.datasync.use_profile_settings" + v-model="simpleSettings['rattail.datasync.use_profile_settings']" + native-value="true" + @input="settingsNeedSaved = true"> + Use these Settings to configure watchers and consumers + </b-checkbox> + </b-field> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h3 class="is-size-3">Watcher Profiles</h3> + </div> + </div> + <div class="level-right"> + <div class="level-item" + v-show="simpleSettings['rattail.datasync.use_profile_settings']"> + <b-button type="is-primary" + @click="newProfile()" + icon-pack="fas" + icon-left="plus"> + New Profile + </b-button> + </div> + <div class="level-item"> + <b-button @click="toggleDisabledProfiles()"> + {{ showDisabledProfiles ? "Hide" : "Show" }} Disabled + </b-button> + </div> + </div> + </div> + + <${b}-table :data="profilesData" + :row-class="getWatcherRowClass"> + <${b}-table-column field="key" + label="Watcher Key" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="watcher_spec" + label="Watcher Spec" + v-slot="props"> + {{ props.row.watcher_spec }} + </${b}-table-column> + <${b}-table-column field="watcher_dbkey" + label="DB Key" + v-slot="props"> + {{ props.row.watcher_dbkey }} + </${b}-table-column> + <${b}-table-column field="watcher_delay" + label="Loop Delay" + v-slot="props"> + {{ props.row.watcher_delay }} sec + </${b}-table-column> + <${b}-table-column field="watcher_retry_attempts" + label="Attempts / Delay" + v-slot="props"> + {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec + </${b}-table-column> + <${b}-table-column field="watcher_default_runas" + label="Default Runas" + v-slot="props"> + {{ props.row.watcher_default_runas }} + </${b}-table-column> + <${b}-table-column label="Consumers" + v-slot="props"> + {{ consumerShortList(props.row) }} + </${b}-table-column> +## <${b}-table-column field="notes" label="Notes"> +## TODO +## ## {{ props.row.notes }} +## </${b}-table-column> + <${b}-table-column field="enabled" + label="Enabled" + v-slot="props"> + {{ props.row.enabled ? "Yes" : "No" }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props" + v-if="simpleSettings['rattail.datasync.use_profile_settings']"> + <a href="#" + class="grid-action" + @click.prevent="editProfile(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + + <a href="#" + class="grid-action has-text-danger" + @click.prevent="deleteProfile(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> + </${b}-table-column> + <template #empty> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </${b}-table> + + <b-modal :active.sync="editProfileShowDialog"> + <div class="card"> + <div class="card-content"> + + <b-field grouped> + + <b-field label="Watcher Key" + :type="editingProfileKey ? null : 'is-danger'"> + <b-input v-model="editingProfileKey" + ref="watcherKeyInput"> + </b-input> + </b-field> + + <b-field label="Default Runas User"> + <b-input v-model="editingProfileWatcherDefaultRunas"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped expanded> + + <b-field label="Watcher Spec" + :type="editingProfileWatcherSpec ? null : 'is-danger'" + expanded> + <b-input v-model="editingProfileWatcherSpec" expanded> + </b-input> + </b-field> + + <b-field label="DB Key"> + <b-input v-model="editingProfileWatcherDBKey"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Loop Delay (seconds)"> + <b-input v-model="editingProfileWatcherDelay"> + </b-input> + </b-field> + + <b-field label="Attempts"> + <b-input v-model="editingProfileWatcherRetryAttempts"> + </b-input> + </b-field> + + <b-field label="Retry Delay (seconds)"> + <b-input v-model="editingProfileWatcherRetryDelay"> + </b-input> + </b-field> + + <b-field :label="`Kwargs (${'$'}{editingProfilePendingWatcherKwargs.length})`"> + <b-button type="is-primary" + icon-pack="fas" + icon-left="edit" + :disabled="editingWatcherKwarg" + @click="editingWatcherKwargs = !editingWatcherKwargs"> + {{ editingWatcherKwargs ? "Stop Editing" : "Edit Kwargs" }} + </b-button> + </b-field> + + </b-field> + + <div v-show="editingWatcherKwargs" + style="display: flex; justify-content: end;"> + + <b-button type="is-primary" + v-show="!editingWatcherKwarg" + icon-pack="fas" + icon-left="plus" + @click="newWatcherKwarg()"> + New Watcher Kwarg + </b-button> + + <div v-show="editingWatcherKwarg"> + + <b-field grouped> + + <b-field label="Key" + :type="editingWatcherKwargKey ? null : 'is-danger'"> + <b-input v-model="editingWatcherKwargKey" + ref="watcherKwargKey"> + </b-input> + </b-field> + + <b-field label="Value" + :type="editingWatcherKwargValue ? null : 'is-danger'"> + <b-input v-model="editingWatcherKwargValue"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-button @click="editingWatcherKwarg = null" + class="control" + > + Cancel + </b-button> + + <b-button type="is-primary" + @click="updateWatcherKwarg()" + class="control"> + Update Kwarg + </b-button> + + </b-field> + + </div> + + + <${b}-table :data="editingProfilePendingWatcherKwargs" + style="margin-left: 1rem;"> + <${b}-table-column field="key" + label="Key" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="value" + label="Value" + v-slot="props"> + {{ props.row.value }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" + @click.prevent="editProfileWatcherKwarg(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + + <a href="#" + class="has-text-danger" + @click.prevent="deleteProfileWatcherKwarg(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> + </${b}-table-column> + <template #empty> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </${b}-table> + + </div> + + <div v-show="!editingWatcherKwargs" + style="display: flex;"> + + <div style="width: 40%;"> + + <b-field label="Watcher consumes its own changes" + v-if="!editingProfilePendingConsumers.length"> + <b-checkbox v-model="editingProfileWatcherConsumesSelf"> + {{ editingProfileWatcherConsumesSelf ? "Yes" : "No" }} + </b-checkbox> + </b-field> + + <${b}-table :data="editingProfilePendingConsumers" + v-if="!editingProfileWatcherConsumesSelf" + :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> + <${b}-table-column field="key" + label="Consumer" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column style="white-space: nowrap;" + v-slot="props"> + {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }} + </${b}-table-column> + <${b}-table-column label="Actions" + v-slot="props"> + <a href="#" + class="grid-action" + @click.prevent="editProfileConsumer(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif + </a> + + <a href="#" + class="grid-action has-text-danger" + @click.prevent="deleteProfileConsumer(props.row)"> + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif + </a> + </${b}-table-column> + <template #empty> + <section class="section"> + <div class="content has-text-grey has-text-centered"> + <p> + <b-icon + pack="fas" + icon="sad-tear" + size="is-large"> + </b-icon> + </p> + <p>Nothing here.</p> + </div> + </section> + </template> + </${b}-table> + + </div> + + <div v-show="!editingConsumer && !editingProfileWatcherConsumesSelf" + style="padding-left: 1rem;"> + <b-button type="is-primary" + @click="newConsumer()" + icon-pack="fas" + icon-left="plus"> + New Consumer + </b-button> + </div> + + <div v-show="editingConsumer" + style="flex-grow: 1; padding-left: 1rem; padding-right: 1rem;"> + + <b-field grouped> + + <b-field label="Consumer Key" + :type="editingConsumerKey ? null : 'is-danger'"> + <b-input v-model="editingConsumerKey" + ref="consumerKeyInput"> + </b-input> + </b-field> + + <b-field label="Runas User"> + <b-input v-model="editingConsumerRunas"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Consumer Spec" + expanded + :type="editingConsumerSpec ? null : 'is-danger'" + > + <b-input v-model="editingConsumerSpec"> + </b-input> + </b-field> + + <b-field label="DB Key"> + <b-input v-model="editingConsumerDBKey"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-field label="Loop Delay"> + <b-input v-model="editingConsumerDelay" + style="width: 8rem;"> + </b-input> + </b-field> + + <b-field label="Attempts"> + <b-input v-model="editingConsumerRetryAttempts" + style="width: 8rem;"> + </b-input> + </b-field> + + <b-field label="Retry Delay"> + <b-input v-model="editingConsumerRetryDelay" + style="width: 8rem;"> + </b-input> + </b-field> + + </b-field> + + <b-field grouped> + + <b-button @click="editingConsumer = null" + class="control"> + Cancel + </b-button> + + <b-button type="is-primary" + @click="updateConsumer()" + :disabled="updateConsumerDisabled" + class="control"> + Update Consumer + </b-button> + + <b-field label="Enabled" horizontal + style="margin-left: 2rem;"> + <b-checkbox v-model="editingConsumerEnabled"> + {{ editingConsumerEnabled ? "Yes" : "No" }} + </b-checkbox> + </b-field> + + </b-field> + </div> + </div> + + <br /> + <b-field grouped> + + <b-button @click="editProfileShowDialog = false" + class="control"> + Cancel + </b-button> + + <b-button type="is-primary" + class="control" + @click="updateProfile()" + :disabled="updateProfileDisabled"> + Update Profile + </b-button> + + <b-field label="Enabled" horizontal + style="margin-left: 2rem;"> + <b-checkbox v-model="editingProfileEnabled"> + {{ editingProfileEnabled ? "Yes" : "No" }} + </b-checkbox> + </b-field> + + </b-field> + + </div> + </div> + </b-modal> + + <br /> + + <h3 class="is-size-3">Misc.</h3> + + <b-field label="Supervisor Process Name" + message="This should be the complete name, including group - e.g. poser:poser_datasync" + expanded> + <b-input name="rattail.datasync.supervisor_process_name" + v-model="simpleSettings['rattail.datasync.supervisor_process_name']" + @input="settingsNeedSaved = true" + expanded> + </b-input> + </b-field> + + <b-field label="Consumer Batch Size" + message="Max number of changes to be consumed at once." + expanded> + <numeric-input name="rattail.datasync.batch_size_limit" + v-model="simpleSettings['rattail.datasync.batch_size_limit']" + @input="settingsNeedSaved = true" /> + </b-field> + + <h3 class="is-size-3">Legacy</h3> + <b-field label="Restart Command" + message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" + expanded> + <b-input name="tailbone.datasync.restart" + v-model="simpleSettings['tailbone.datasync.restart']" + @input="settingsNeedSaved = true" + expanded> + </b-input> + </b-field> + +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.showConfigFilesNote = false + ThisPageData.profilesData = ${json.dumps(profiles_data)|n} + ThisPageData.showDisabledProfiles = false + + ThisPageData.editProfileShowDialog = false + ThisPageData.editingProfile = null + ThisPageData.editingProfileKey = null + ThisPageData.editingProfileWatcherSpec = null + ThisPageData.editingProfileWatcherDBKey = null + ThisPageData.editingProfileWatcherDelay = 1 + ThisPageData.editingProfileWatcherRetryAttempts = 1 + ThisPageData.editingProfileWatcherRetryDelay = 1 + ThisPageData.editingProfileWatcherDefaultRunas = null + ThisPageData.editingProfileWatcherConsumesSelf = false + ThisPageData.editingProfilePendingConsumers = [] + ThisPageData.editingProfileEnabled = true + + ThisPageData.editingConsumer = null + ThisPageData.editingConsumerKey = null + ThisPageData.editingConsumerSpec = null + ThisPageData.editingConsumerDBKey = null + ThisPageData.editingConsumerDelay = 1 + ThisPageData.editingConsumerRetryAttempts = 1 + ThisPageData.editingConsumerRetryDelay = 1 + ThisPageData.editingConsumerRunas = null + ThisPageData.editingConsumerEnabled = true + + ThisPage.computed.updateConsumerDisabled = function() { + if (!this.editingConsumerKey) { + return true + } + if (!this.editingConsumerSpec) { + return true + } + return false + } + + ThisPage.computed.updateProfileDisabled = function() { + if (this.editingConsumer) { + return true + } + if (!this.editingProfileKey) { + return true + } + if (!this.editingProfileWatcherSpec) { + return true + } + return false + } + + ThisPage.methods.toggleDisabledProfiles = function() { + this.showDisabledProfiles = !this.showDisabledProfiles + } + + ThisPage.methods.getWatcherRowClass = function(row, i) { + if (!row.enabled) { + if (!this.showDisabledProfiles) { + return 'invisible-watcher' + } + return 'has-background-warning' + } + } + + ThisPage.methods.consumerShortList = function(row) { + let keys = [] + if (row.watcher_consumes_self) { + keys.push('self (watcher)') + } else { + for (let consumer of row.consumers_data) { + if (consumer.enabled) { + keys.push(consumer.key) + } + } + } + return keys.join(', ') + } + + ThisPage.methods.newProfile = function() { + this.editingProfile = {watcher_kwargs_data: []} + this.editingConsumer = null + this.editingWatcherKwargs = false + + this.editingProfileKey = null + this.editingProfileWatcherSpec = null + this.editingProfileWatcherDBKey = null + this.editingProfileWatcherDelay = 1 + this.editingProfileWatcherRetryAttempts = 1 + this.editingProfileWatcherRetryDelay = 1 + this.editingProfileWatcherDefaultRunas = null + this.editingProfileWatcherConsumesSelf = false + this.editingProfileEnabled = true + this.editingProfilePendingConsumers = [] + this.editingProfilePendingWatcherKwargs = [] + + this.editProfileShowDialog = true + this.$nextTick(() => { + this.$refs.watcherKeyInput.focus() + }) + } + + ThisPage.methods.editProfile = function(row) { + this.editingProfile = row + this.editingConsumer = null + this.editingWatcherKwargs = false + + this.editingProfileKey = row.key + this.editingProfileWatcherSpec = row.watcher_spec + this.editingProfileWatcherDBKey = row.watcher_dbkey + this.editingProfileWatcherDelay = row.watcher_delay + this.editingProfileWatcherRetryAttempts = row.watcher_retry_attempts + this.editingProfileWatcherRetryDelay = row.watcher_retry_delay + this.editingProfileWatcherDefaultRunas = row.watcher_default_runas + this.editingProfileWatcherConsumesSelf = row.watcher_consumes_self + this.editingProfileEnabled = row.enabled + + this.editingProfilePendingWatcherKwargs = [] + for (let kwarg of row.watcher_kwargs_data) { + let pending = { + original_key: kwarg.key, + key: kwarg.key, + value: kwarg.value, + } + this.editingProfilePendingWatcherKwargs.push(pending) + } + + this.editingProfilePendingConsumers = [] + for (let consumer of row.consumers_data) { + const pending = { + ...consumer, + original_key: consumer.key, + } + this.editingProfilePendingConsumers.push(pending) + } + + this.editProfileShowDialog = true + } + + ThisPageData.editingWatcherKwargs = false + ThisPageData.editingProfilePendingWatcherKwargs = [] + ThisPageData.editingWatcherKwarg = null + ThisPageData.editingWatcherKwargKey = null + ThisPageData.editingWatcherKwargValue = null + + ThisPage.methods.newWatcherKwarg = function() { + this.editingWatcherKwargKey = null + this.editingWatcherKwargValue = null + this.editingWatcherKwarg = {key: null, value: null} + this.$nextTick(() => { + this.$refs.watcherKwargKey.focus() + }) + } + + ThisPage.methods.editProfileWatcherKwarg = function(row) { + this.editingWatcherKwargKey = row.key + this.editingWatcherKwargValue = row.value + this.editingWatcherKwarg = row + } + + ThisPage.methods.updateWatcherKwarg = function() { + let pending = this.editingWatcherKwarg + let isNew = !pending.key + + pending.key = this.editingWatcherKwargKey + pending.value = this.editingWatcherKwargValue + + if (isNew) { + this.editingProfilePendingWatcherKwargs.push(pending) + } + + this.editingWatcherKwarg = null + } + + ThisPage.methods.deleteProfileWatcherKwarg = function(row) { + let i = this.editingProfilePendingWatcherKwargs.indexOf(row) + this.editingProfilePendingWatcherKwargs.splice(i, 1) + } + + ThisPage.methods.findConsumer = function(profileConsumers, key) { + for (const consumer of profileConsumers) { + if (consumer.key == key) { + return consumer + } + } + } + + ThisPage.methods.updateProfile = function() { + const row = this.editingProfile + + const newRow = !row.key + let originalProfile = null + if (newRow) { + row.consumers_data = [] + this.profilesData.push(row) + } else { + originalProfile = this.findProfile(row) + } + + row.key = this.editingProfileKey + row.watcher_spec = this.editingProfileWatcherSpec + row.watcher_dbkey = this.editingProfileWatcherDBKey + row.watcher_delay = this.editingProfileWatcherDelay + row.watcher_retry_attempts = this.editingProfileWatcherRetryAttempts + row.watcher_retry_delay = this.editingProfileWatcherRetryDelay + row.watcher_default_runas = this.editingProfileWatcherDefaultRunas + row.watcher_consumes_self = this.editingProfileWatcherConsumesSelf + row.enabled = this.editingProfileEnabled + + // track which keys still belong (persistent) + let persistentWatcherKwargs = [] + + // transfer pending data to profile watcher kwargs + for (let pending of this.editingProfilePendingWatcherKwargs) { + persistentWatcherKwargs.push(pending.key) + if (pending.original_key) { + let kwarg = this.findOriginalWatcherKwarg(pending.original_key) + kwarg.key = pending.key + kwarg.value = pending.value + } else { + row.watcher_kwargs_data.push(pending) + } + } + + // remove any kwargs not being persisted + let removeWatcherKwargs = [] + for (let kwarg of row.watcher_kwargs_data) { + let i = persistentWatcherKwargs.indexOf(kwarg.key) + if (i < 0) { + removeWatcherKwargs.push(kwarg) + } + } + for (let kwarg of removeWatcherKwargs) { + let i = row.watcher_kwargs_data.indexOf(kwarg) + row.watcher_kwargs_data.splice(i, 1) + } + + // track which keys still belong (persistent) + let persistentConsumers = [] + + // transfer pending data to profile consumers + for (let pending of this.editingProfilePendingConsumers) { + persistentConsumers.push(pending.key) + if (pending.original_key) { + const consumer = this.findConsumer(originalProfile.consumers_data, + pending.original_key) + consumer.key = pending.key + consumer.consumer_spec = pending.consumer_spec + consumer.consumer_dbkey = pending.consumer_dbkey + consumer.consumer_delay = pending.consumer_delay + consumer.consumer_retry_attempts = pending.consumer_retry_attempts + consumer.consumer_retry_delay = pending.consumer_retry_delay + consumer.consumer_runas = pending.consumer_runas + consumer.enabled = pending.enabled + } else { + row.consumers_data.push(pending) + } + } + + // remove any consumers not being persisted + let removeConsumers = [] + for (let consumer of row.consumers_data) { + let i = persistentConsumers.indexOf(consumer.key) + if (i < 0) { + removeConsumers.push(consumer) + } + } + for (let consumer of removeConsumers) { + let i = row.consumers_data.indexOf(consumer) + row.consumers_data.splice(i, 1) + } + + if (!newRow) { + + // nb. must explicitly update the original data row; + // otherwise (with vue3) it will remain stale and + // submitting the form will keep same settings! + // TODO: this probably means i am doing something + // sloppy, but at least this hack fixes for now. + const profile = this.findProfile(row) + for (const key of Object.keys(row)) { + profile[key] = row[key] + } + } + + this.settingsNeedSaved = true + this.editProfileShowDialog = false + } + + ThisPage.methods.findProfile = function(row) { + for (const profile of this.profilesData) { + if (profile.key == row.key) { + return profile + } + } + } + + ThisPage.methods.deleteProfile = function(row) { + if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) { + let i = this.profilesData.indexOf(row) + this.profilesData.splice(i, 1) + this.settingsNeedSaved = true + } + } + + ThisPage.methods.newConsumer = function() { + this.editingConsumerKey = null + this.editingConsumerSpec = null + this.editingConsumerDBKey = null + this.editingConsumerDelay = 1 + this.editingConsumerRetryAttempts = 1 + this.editingConsumerRetryDelay = 1 + this.editingConsumerRunas = null + this.editingConsumerEnabled = true + this.editingConsumer = {} + this.$nextTick(() => { + this.$refs.consumerKeyInput.focus() + }) + } + + ThisPage.methods.editProfileConsumer = function(row) { + this.editingConsumerKey = row.key + this.editingConsumerSpec = row.consumer_spec + this.editingConsumerDBKey = row.consumer_dbkey + this.editingConsumerDelay = row.consumer_delay + this.editingConsumerRetryAttempts = row.consumer_retry_attempts + this.editingConsumerRetryDelay = row.consumer_retry_delay + this.editingConsumerRunas = row.consumer_runas + this.editingConsumerEnabled = row.enabled + this.editingConsumer = row + } + + ThisPage.methods.updateConsumer = function() { + const pending = this.findConsumer( + this.editingProfilePendingConsumers, + this.editingConsumer.key) + const isNew = !pending.key + + pending.key = this.editingConsumerKey + pending.consumer_spec = this.editingConsumerSpec + pending.consumer_dbkey = this.editingConsumerDBKey + pending.consumer_delay = this.editingConsumerDelay + pending.consumer_retry_attempts = this.editingConsumerRetryAttempts + pending.consumer_retry_delay = this.editingConsumerRetryDelay + pending.consumer_runas = this.editingConsumerRunas + pending.enabled = this.editingConsumerEnabled + + if (isNew) { + this.editingProfilePendingConsumers.push(pending) + } + this.editingConsumer = null + } + + ThisPage.methods.deleteProfileConsumer = function(row) { + if (confirm("Are you sure you want to delete the '" + row.key + "' consumer?")) { + let i = this.editingProfilePendingConsumers.indexOf(row) + this.editingProfilePendingConsumers.splice(i, 1) + } + } + + % if request.has_perm('datasync.restart'): + ThisPageData.restartingDatasync = false + ThisPageData.restartDatasyncFormButtonText = "Restart Datasync" + ThisPage.methods.restartDatasync = function(e) { + if (this.settingsNeedSaved) { + alert("You have unsaved changes. Please save or undo them first.") + e.preventDefault() + } + } + ThisPage.methods.submitRestartDatasyncForm = function() { + this.restartingDatasync = true + this.restartDatasyncFormButtonText = "Restarting Datasync..." + } + % endif + + </script> +</%def> diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako new file mode 100644 index 00000000..e14686f8 --- /dev/null +++ b/tailbone/templates/datasync/status.mako @@ -0,0 +1,174 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">${index_title}</%def> + +<%def name="content_title()"></%def> + +<%def name="page_content()"> + % if expose_websockets and not supervisor_error: + <b-notification type="is-warning" + :active="websocketBroken" + :closable="false"> + Server connection was broken - please refresh page to see accurate status! + </b-notification> + % endif + <b-field label="Supervisor Status"> + <div style="display: flex;"> + + % if supervisor_error: + <pre class="has-background-warning">${supervisor_error}</pre> + % else: + <pre :class="(processInfo && processInfo.statename == 'RUNNING') ? 'has-background-success' : 'has-background-warning'">{{ processDescription }}</pre> + % endif + + <div style="margin-left: 1rem;"> + % if request.has_perm('datasync.restart'): + ${h.form(url('datasync.restart'), **{'@submit': 'restartProcess'})} + ${h.csrf_token(request)} + <b-button type="is-primary" + native-type="submit" + icon-pack="fas" + icon-left="redo" + :disabled="restartingProcess"> + {{ restartingProcess ? "Working, please wait..." : "Restart Process" }} + </b-button> + ${h.end_form()} + % endif + </div> + + </div> + </b-field> + + <h3 class="is-size-3">Watcher Status</h3> + + <${b}-table :data="watchers"> + <${b}-table-column field="key" + label="Watcher" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="spec" + label="Spec" + v-slot="props"> + {{ props.row.spec }} + </${b}-table-column> + <${b}-table-column field="dbkey" + label="DB Key" + v-slot="props"> + {{ props.row.dbkey }} + </${b}-table-column> + <${b}-table-column field="delay" + label="Delay" + v-slot="props"> + {{ props.row.delay }} second(s) + </${b}-table-column> + <${b}-table-column field="lastrun" + label="Last Watched" + v-slot="props"> + <span v-html="props.row.lastrun"></span> + </${b}-table-column> + <${b}-table-column field="status" + label="Status" + v-slot="props"> + <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> + {{ props.row.status }} + </span> + </${b}-table-column> + </${b}-table> + + <h3 class="is-size-3">Consumer Status</h3> + + <${b}-table :data="consumers"> + <${b}-table-column field="key" + label="Consumer" + v-slot="props"> + {{ props.row.key }} + </${b}-table-column> + <${b}-table-column field="spec" + label="Spec" + v-slot="props"> + {{ props.row.spec }} + </${b}-table-column> + <${b}-table-column field="dbkey" + label="DB Key" + v-slot="props"> + {{ props.row.dbkey }} + </${b}-table-column> + <${b}-table-column field="delay" + label="Delay" + v-slot="props"> + {{ props.row.delay }} second(s) + </${b}-table-column> + <${b}-table-column field="changes" + label="Pending Changes" + v-slot="props"> + {{ props.row.changes }} + </${b}-table-column> + <${b}-table-column field="status" + label="Status" + v-slot="props"> + <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> + {{ props.row.status }} + </span> + </${b}-table-column> + </${b}-table> +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ThisPageData.processInfo = ${json.dumps(process_info)|n} + + ThisPage.computed.processDescription = function() { + let info = this.processInfo + if (info) { + return `${'$'}{info.group}:${'$'}{info.name} ${'$'}{info.statename} ${'$'}{info.description}` + } else { + return "NO PROCESS INFO AVAILABLE" + } + } + + ThisPageData.restartingProcess = false + ThisPageData.watchers = ${json.dumps(watcher_data)|n} + ThisPageData.consumers = ${json.dumps(consumer_data)|n} + + ThisPage.methods.restartProcess = function() { + this.restartingProcess = true + } + + % if expose_websockets and not supervisor_error: + + ThisPageData.ws = null + ThisPageData.websocketBroken = false + + ThisPage.mounted = function() { + + ## TODO: should be a cleaner way to get this url? + let url = '${url('ws.datasync.status')}' + url = url.replace(/^http(s?):/, 'ws$1:') + + this.ws = new WebSocket(url) + let that = this + + this.ws.onclose = (event) => { + // websocket closing means 1 of 2 things: + // - user navigated away from page intentionally + // - server connection was broken somehow + // only one of those is "bad" and we only want to + // display warning in 2nd case. so we simply use a + // brief delay to "rule out" the 1st scenario + setTimeout(() => { that.websocketBroken = true }, + 3000) + } + + this.ws.onmessage = (event) => { + that.processInfo = JSON.parse(event.data) + } + } + + % endif + + </script> +</%def> diff --git a/tailbone/templates/deform/autocomplete_jquery.pt b/tailbone/templates/deform/autocomplete_jquery.pt 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 new file mode 100644 index 00000000..408fa1cb --- /dev/null +++ b/tailbone/templates/deform/checkbox.pt @@ -0,0 +1,15 @@ +<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 new file mode 100644 index 00000000..b033a8e2 --- /dev/null +++ b/tailbone/templates/deform/select.pt @@ -0,0 +1,50 @@ +<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; + input_handler input_handler|'';" + tal:omit-tag=""> + + <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, item[0]); + class css_class; + 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/versions/index.mako b/tailbone/templates/departments/versions/index.mako deleted file mode 100644 index d26bebd4..00000000 --- a/tailbone/templates/departments/versions/index.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/index.mako" /> -${parent.body()} diff --git a/tailbone/templates/departments/versions/view.mako b/tailbone/templates/departments/versions/view.mako deleted file mode 100644 index 94567acb..00000000 --- a/tailbone/templates/departments/versions/view.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/view.mako" /> -${parent.body()} 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 new file mode 100644 index 00000000..a78bd770 --- /dev/null +++ b/tailbone/templates/diff.mako @@ -0,0 +1,19 @@ +## -*- coding: utf-8; -*- +<table class="diff ${diff.nature} ${' monospace' if diff.monospace else ''}"> + <thead> + <tr> + % for column in diff.columns: + <th>${column}</th> + % endfor + </tr> + </thead> + <tbody> + % for field in diff.fields: + <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> + </tr> + % endfor + </tbody> +</table> 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 a5ce1c07..00000000 --- a/tailbone/templates/email/profiles/view.mako +++ /dev/null @@ -1,30 +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()} - -<form action="${url('email.preview')}" name="send-email-preview" method="post"> - <a id="preview-html" class="button" href="${url('email.preview')}?key=${instance['key']}&type=html" target="_blank">Preview HTML</a> - <a id="preview-txt" class="button" href="${url('email.preview')}?key=${instance['key']}&type=txt" target="_blank">Preview TXT</a> - or - <input type="text" name="recipient" value="${request.user.email_address or ''}" /> - <input type="submit" name="send_${instance['key']}" value="Send Preview Email" /> -</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/exception.mako b/tailbone/templates/exception.mako new file mode 100644 index 00000000..47cbab42 --- /dev/null +++ b/tailbone/templates/exception.mako @@ -0,0 +1,34 @@ +## -*- coding: utf-8 -*- +## TODO: this should leverage the underlying base template somehow..yes? +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> + <title>${project_title} » Error!</title> + </head> + + <body> + <p> + An unexpected error has occurred in ${project_title}. + </p> + + <p> + But don't worry...details of the error have been emailed to the sysadmins, so we're on it! + </p> + + <p> + In the meantime, you might try once or twice again, whatever it was you were just doing. + Occasionally certain errors will go away on their own. But if the problem persists, please + do NOT keep trying, because the sysadmins will get a new email for each error. :) + </p> + + <p> + If you would like to be notified when this error has been fixed, you might consider using the + Feedback tool (top right on the previous page). In some cases it may help to know what + you were trying to do, or if you noticed any strange behavior or other clues. + </p> + + <p> + <a href="javascript:history.back();">Go Back</a> + </body> +</html> diff --git a/tailbone/templates/feedback.mako b/tailbone/templates/feedback.mako deleted file mode 100644 index ee2b4a35..00000000 --- a/tailbone/templates/feedback.mako +++ /dev/null @@ -1,56 +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.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/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 8ffcc1e6..00000000 --- a/tailbone/templates/forms/fieldset.mako +++ /dev/null @@ -1,42 +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 2f082043..00000000 --- a/tailbone/templates/forms/form.mako +++ /dev/null @@ -1,20 +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.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 fb6067d9..00000000 --- a/tailbone/templates/forms/lib.mako +++ /dev/null @@ -1,12 +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/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/grid.mako b/tailbone/templates/grid.mako deleted file mode 100644 index c077a1ab..00000000 --- a/tailbone/templates/grid.mako +++ /dev/null @@ -1,38 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> - -<%def name="context_menu_items()"></%def> - -<%def name="form()"> - % if search: - ${search.render()} - % else: - - % endif -</%def> - -<%def name="tools()"></%def> - -<div class="grid-wrapper"> - - <table class="grid-header"> - <tr> - <td rowspan="2" class="form"> - ${self.form()} - </td> - <td class="context-menu"> - <ul> - ${self.context_menu_items()} - </ul> - </td> - </tr> - <tr> - <td class="tools"> - ${self.tools()} - </td> - </tr> - </table><!-- grid-header --> - - ${grid} - -</div><!-- grid-wrapper --> 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 new file mode 100644 index 00000000..60f9a3b8 --- /dev/null +++ b/tailbone/templates/grids/complete.mako @@ -0,0 +1,930 @@ +## -*- coding: utf-8; -*- + +<% request.register_component(grid.vue_tagname, grid.vue_component) %> + +<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 + </div> + </div> + + <div style="display: flex; flex-direction: column; justify-content: space-between;"> + + <div class="context-menu"> + % if context_menu: + <ul id="context-menu"> + ## TODO: stop using |n filter + ${context_menu|n} + </ul> + % endif + </div> + + <div class="grid-tools-wrapper"> + % if tools: + <div class="grid-tools"> + ## TODO: stop using |n filter + ${tools|n} + </div> + % endif + </div> + + </div> + + </div> + + <${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/grid.mako b/tailbone/templates/grids/grid.mako deleted file mode 100644 index d2fe16d5..00000000 --- a/tailbone/templates/grids/grid.mako +++ /dev/null @@ -1,64 +0,0 @@ -## -*- coding: utf-8 -*- -<div ${grid.div_attrs()}> - <table> - <thead> - <tr> - % if grid.checkboxes: - <th class="checkbox">${h.checkbox('check-all')}</th> - % endif - % for field in grid.iter_fields(): - ${grid.column_header(field)} - % endfor - % for col in grid.extra_columns: - <th>${col.label}</td> - % endfor - % if grid.viewable: - <th> </th> - % endif - % if grid.editable: - <th> </th> - % endif - % if grid.deletable: - <th> </th> - % endif - </tr> - </thead> - <tbody> - % for i, row in enumerate(grid.iter_rows(), 1): - <tr ${grid.get_row_attrs(row, i)}> - % if grid.checkboxes: - <td class="checkbox">${grid.checkbox(row)}</td> - % endif - % for field in grid.iter_fields(): - <td class="${grid.cell_class(field)}">${grid.render_field(field)}</td> - % endfor - % for col in grid.extra_columns: - <td class="${col.name}">${col.callback(row)}</td> - % endfor - % if grid.viewable: - <td class="view" url="${grid.get_view_url(row)}"> </td> - % endif - % if grid.editable: - <td class="edit" url="${grid.get_edit_url(row)}"> </td> - % endif - % if grid.deletable: - <td class="delete" url="${grid.get_delete_url(row)}"> </td> - % endif - </tr> - % endfor - </tbody> - </table> - % if grid.pager: - <div class="pager"> - <p class="showing"> - showing ${grid.pager.first_item} thru ${grid.pager.last_item} of ${grid.pager.item_count} - (page ${grid.pager.page} of ${grid.pager.page_count}) - </p> - <p class="page-links"> - ${h.select('grid-page-count', grid.pager.items_per_page, grid.page_count_options())} - per page - ${grid.page_links()} - </p> - </div> - % endif -</div> diff --git a/tailbone/templates/newgrids/nav.mako b/tailbone/templates/grids/nav.mako similarity index 100% rename from tailbone/templates/newgrids/nav.mako rename to tailbone/templates/grids/nav.mako 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 7d60bf2f..54e44d57 100644 --- a/tailbone/templates/home.mako +++ b/tailbone/templates/home.mako @@ -1,9 +1,7 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/home.mako" /> -<%def name="title()">Home</%def> - -<div style="text-align: center;"> - ${h.image(request.static_url('tailbone:static/img/home_logo.png'), "Rattail Logo")} - <h1>Welcome to Tailbone</h1> -</div> +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} +</%def> 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 3fe1089f..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,41 +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())} - - % 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/versions/index.mako b/tailbone/templates/labels/profiles/versions/index.mako deleted file mode 100644 index d26bebd4..00000000 --- a/tailbone/templates/labels/profiles/versions/index.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/index.mako" /> -${parent.body()} diff --git a/tailbone/templates/labels/profiles/versions/view.mako b/tailbone/templates/labels/profiles/versions/view.mako deleted file mode 100644 index 94567acb..00000000 --- a/tailbone/templates/labels/profiles/versions/view.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/view.mako" /> -${parent.body()} diff --git a/tailbone/templates/labels/profiles/view.mako b/tailbone/templates/labels/profiles/view.mako index d2f5894d..b93570af 100644 --- a/tailbone/templates/labels/profiles/view.mako +++ b/tailbone/templates/labels/profiles/view.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> <%def name="head_tags()"> @@ -22,24 +22,26 @@ <li>${h.link_to("Edit Printer Settings", url('labelprofiles.printer_settings', uuid=instance.uuid))}</li> % endif % endif - % if version_count is not Undefined and request.has_perm('labelprofile.versions.view'): - <li>${h.link_to("View Change History ({0})".format(version_count), url('labelprofile.versions', uuid=instance.uuid))}</li> +</%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 852175a1..d2ea7828 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -1,42 +1,17 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/auth/login.mako" /> -<%def name="title()">Login</%def> - -<%def name="head_tags()"> - ${parent.head_tags()} - ${h.javascript_link(request.static_url('tailbone:static/js/login.js'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/login.css'))} +## TODO: this will not be needed with wuttaform +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style> + .card-content .buttons { + justify-content: right; + } + </style> </%def> -<%def name="logo()"> - ${h.image(request.static_url('tailbone:static/img/home_logo.png'), "Rattail Logo", id='logo')} +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} </%def> - -${self.logo()} - -<div class="form"> - ${h.form('')} - <input type="hidden" name="referrer" value="${referrer}" /> - - % if error: - <div class="error">${error}</div> - % endif - - <div class="field-wrapper"> - <label for="username">Username</label> - <input type="text" name="username" id="username" value="" /> - </div> - - <div class="field-wrapper"> - <label for="password">Password</label> - <input type="password" name="password" id="password" value="" /> - </div> - - <div class="buttons"> - ${h.submit('submit', "Login")} - <input type="reset" value="Reset" /> - </div> - - ${h.end_form()} -</div> 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 new file mode 100644 index 00000000..4c7e4662 --- /dev/null +++ b/tailbone/templates/master/clone.mako @@ -0,0 +1,50 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + +<%def name="title()">Clone ${model_title}: ${instance_title}</%def> + +<%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> + +<%def name="render_form_buttons()"> + <br /> + <b-notification :closable="false"> + Are you sure about this? + </b-notification> + <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> + +<%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 510de6a9..d7dcbbd8 100644 --- a/tailbone/templates/master/create.mako +++ b/tailbone/templates/master/create.mako @@ -1,18 +1,6 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%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="context_menu_items()"> - % if request.has_perm('{}.list'.format(permission_prefix)): - <li>${h.link_to("Back to {}".format(model_title_plural), index_url)}</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/create_row.mako b/tailbone/templates/master/create_row.mako new file mode 100644 index 00000000..1ad0ab1f --- /dev/null +++ b/tailbone/templates/master/create_row.mako @@ -0,0 +1,10 @@ +## -*- coding: utf-8 -*- +<%inherit file="/master/create.mako" /> + +<%def name="title()">New ${row_model_title}</%def> + +<%def name="context_menu_items()"> + <li>${h.link_to("Back to {}".format(model_title), index_url)}</li> +</%def> + +${parent.body()} diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 1083d421..d2f517d9 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -1,41 +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="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 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="confirmation()"> +<%def name="render_form_buttons()"> + <br /> + <b-notification type="is-danger" :closable="false"> + Are you sure about this? + </b-notification> <br /> - <p>Are you sure about this?</p> - ${h.form(request.current_route_url())} + ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} + ${h.csrf_token(request)} <div class="buttons"> - <button type="button" onclick="$(this).parents('form').submit();">Yes, please DELETE this record forever!</button> - <a class="button" href="${form.cancel_url}">Whoops, nevermind...</a> + <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> -<p>You are about to delete the following ${model_title} record:</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 79bc533f..a03912e6 100644 --- a/tailbone/templates/master/edit.mako +++ b/tailbone/templates/master/edit.mako @@ -1,25 +1,8 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> -<%def name="title()">Edit ${model_title}: ${instance_title}</%def> +<%def name="title()">${index_title} » ${instance_title} » Edit</%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.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> +<%def name="content_title()">Edit: ${instance_title}</%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 c3cfc0af..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 @@ -10,7 +10,7 @@ <li>${h.link_to("Delete this {}".format(row_model_title), row_action_url('delete', instance))}</li> % endif % if master.rows_creatable and request.has_perm('{}.create'.format(row_permission_prefix)): - <li>${h.link_to("Create a new {}".format(row_model_title), url('{}.create'.format(row_route_prefix)))}</li> + <li>${h.link_to("Create a new {}".format(row_model_title), url('{}.create_row'.format(route_prefix), uuid=row_parent.uuid))}</li> % endif </%def> 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 56b4faae..a2d26c60 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- ## ############################################################################## ## ## Default master 'index' template. Features a prominent data table and @@ -6,26 +6,627 @@ ## include a "tools" section, just above the grid on the right. ## ## ############################################################################## -<%inherit file="/base.mako" /> +<%inherit file="/page.mako" /> -<%def name="title()">${grid.model_title_plural}</%def> +<%def name="title()">${index_title}</%def> + +<%def name="content_title()"></%def> + +<%def name="grid_tools()"> + + ## grid totals + % if getattr(master, 'supports_grid_totals', False): + <div style="display: flex; align-items: center;"> + <b-button v-if="gridTotalsDisplay == null" + :disabled="gridTotalsFetching" + @click="gridTotalsFetch()"> + {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} + </b-button> + <div v-if="gridTotalsDisplay != null" + class="control"> + Totals: {{ gridTotalsDisplay }} + </div> + </div> + % endif + + ## download search results + % if 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 name="head_tags()"> - ${parent.head_tags()} - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} - <script type="text/javascript"> - $(function() { - $('.newgrid-wrapper').gridwrapper(); - }); - </script> </%def> -<%def name="context_menu_items()"> - % if master.creatable and request.has_perm('{}.create'.format(grid.permission_prefix)): - <li>${h.link_to("Create a new {}".format(grid.model_title), url('{}.create'.format(grid.route_prefix)))}</li> +## 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="grid_tools()"></%def> +<%def name="render_grid_component()"> + ${grid.render_vue_tag()} +</%def> -${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} +############################## +## 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"> + + % if getattr(master, 'supports_grid_totals', False): + ${grid.vue_component}Data.gridTotalsDisplay = null + ${grid.vue_component}Data.gridTotalsFetching = false + + ${grid.vue_component}.methods.gridTotalsFetch = function() { + this.gridTotalsFetching = true + + 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.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 + + ## 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 + + % 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 + + ## 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() + } + } + % 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 + } + + 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="make_vue_components()"> + ${parent.make_vue_components()} + <script> + ${grid.vue_component}.data = function() { return ${grid.vue_component}Data } + Vue.component('${grid.vue_tagname}', ${grid.vue_component}) + </script> +</%def> diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako new file mode 100644 index 00000000..487d258d --- /dev/null +++ b/tailbone/templates/master/merge.mako @@ -0,0 +1,183 @@ +## -*- coding: utf-8 -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Merge 2 ${model_title_plural}</%def> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + p { + margin: 20px auto; + } + p.warning, + p.warning strong { + color: red; + } + a.merge-object { + font-weight: bold; + } + + table.diff { + background-color: White; + border-collapse: collapse; + border-left: 1px solid black; + border-top: 1px solid black; + font-size: 11pt; + 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.diff tr.diff td.keep-value { + background-color: #cfc; + } + table.diff tr.diff td.remove-value { + background-color: #fcc; + } + table.diff td.result-value.diff { + background-color: #fe8; + } + div.buttons { + margin-top: 20px; + } + </style> +</%def> + +<%def name="context_menu_items()"> + <li>${h.link_to("Back to {}".format(model_title_plural), url(route_prefix))}</li> +</%def> + +<%def name="page_content()"> + <p> + You are about to <strong>merge</strong> two ${model_title} records, + (possibly) along with various related data. The tool you are using now + is somewhat generic and is not able to give you the full picture of the + implications of this merge. You are urged to proceed with caution! + </p> + + <p 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> + + <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> + +<%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 new file mode 100644 index 00000000..a6bb14f0 --- /dev/null +++ b/tailbone/templates/master/versions.mako @@ -0,0 +1,31 @@ +## -*- coding: utf-8; -*- +## ############################################################################## +## +## Default master 'versions' template, for showing an object's version history. +## +## ############################################################################## +<%inherit file="/page.mako" /> + +<%def name="title()">${model_title_plural} » ${instance_title} » history</%def> + +<%def name="content_title()"> + Version History +</%def> + +<%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 fcddff05..118c028c 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -1,47 +1,336 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> -<%def name="title()">${model_title}: ${instance_title}</%def> +<%def name="title()">${index_title} » ${instance_title}</%def> -<%def name="head_tags()"> - ${parent.head_tags()} - % if master.has_rows: - ${h.javascript_link(request.static_url('tailbone:static/js/jquery.ui.tailbone.js'))} - <script type="text/javascript"> - $(function() { - $('.newgrid-wrapper').gridwrapper(); - }); - </script> - <style type="text/css"> - .newgrid-wrapper { - margin-top: 10px; +<%def name="content_title()"> + ${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: + <span><i class="fa fa-hand-pointer"></i></span> + % endif + </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 +</%def> + +<%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 expose_versions: + ${versions_grid.render_vue_template()} + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if getattr(master, 'touchable', False) and master.has_perm('touch'): + + WholePageData.touchSubmitting = false + + WholePage.methods.touchRecord = function() { + this.touchSubmitting = true + location.href = '${master.get_action_url('touch', instance)}' } - </style> - % endif + + % 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="context_menu_items()"> - <li>${h.link_to("Back to {}".format(model_title_plural), index_url)}</li> - <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li> - % 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> +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + % if getattr(master, 'has_rows', False): + ${rows_grid.render_vue_finalize()} % 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> + % if expose_versions: + ${versions_grid.render_vue_finalize()} % endif </%def> - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<div class="form-wrapper"> - ${form.render()|n} -</div><!-- form-wrapper --> - -% if master.has_rows: - ${rows_grid|n} -% endif diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako index dc5fd532..623a33a0 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -1,25 +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()"> + ${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 master.rows_creatable and request.has_perm('{}.create'.format(permission_prefix)): + % 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 new file mode 100644 index 00000000..dfe03a64 --- /dev/null +++ b/tailbone/templates/master/view_version.mako @@ -0,0 +1,58 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">changes @ ver ${transaction.id}</%def> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + .this-page-content { + overflow: auto; + } + + .versions-wrapper { + margin-left: 2rem; + } + + </style> +</%def> + +<%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> +</%def> + + +${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 e8c8fe20..16a05ee2 100644 --- a/tailbone/templates/messages/archive/index.mako +++ b/tailbone/templates/messages/archive/index.mako @@ -1,27 +1,13 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/messages/index.mako" /> <%def name="title()">Message Archive</%def> -<%def name="head_tags()"> - ${parent.head_tags()} - <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.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 04d53c11..39236f75 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -1,88 +1,29 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/create.mako" /> +<%namespace file="/messages/recipients.mako" import="message_recipients_template" /> -<%def name="head_tags()"> - ${parent.head_tags()} - ${h.javascript_link(request.static_url('tailbone:static/js/lib/tag-it.min.js'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.tagit.css'))} - <script type="text/javascript"> +<%def name="content_title()"></%def> - 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 - ]); +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.message_recipients.js'))} +</%def> - $(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> +<%def name="extra_styles()"> + ${parent.extra_styles()} <style type="text/css"> - .field-wrapper.subject .field input[type="text"], - .field-wrapper.body .field textarea { - width: 99%; + .this-page-content { + width: 100%; + } + + .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> @@ -91,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 a9100b71..2ac24b9e 100644 --- a/tailbone/templates/messages/inbox/index.mako +++ b/tailbone/templates/messages/inbox/index.mako @@ -1,27 +1,13 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/messages/index.mako" /> <%def name="title()">Message Inbox</%def> -<%def name="head_tags()"> - ${parent.head_tags()} - <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.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 dbc13fe3..eaa4b6c9 100644 --- a/tailbone/templates/messages/index.mako +++ b/tailbone/templates/messages/index.mako @@ -1,54 +1,48 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="head_tags()"> - ${parent.head_tags()} - <script type="text/javascript"> - - var destination = null; - - function update_move_button() { - var count = $('.newgrid tbody 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(); - - $('.newgrid-wrapper').on('click', 'thead th.checkbox input', function() { - update_move_button(); - }); - - $('.newgrid-wrapper').on('click', 'tbody td.checkbox input', function() { - update_move_button(); - }); - - $('form[name="move-selected"]').submit(function() { - var uuids = []; - $('.newgrid tbody 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 c4598e39..36418698 100644 --- a/tailbone/templates/messages/view.mako +++ b/tailbone/templates/messages/view.mako @@ -1,45 +1,20 @@ -## -*- 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"> - .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> - <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="context_menu_items()"> @@ -47,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> @@ -65,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> @@ -83,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/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/newbatch/create.mako b/tailbone/templates/newbatch/create.mako deleted file mode 100644 index 5d2a2e53..00000000 --- a/tailbone/templates/newbatch/create.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/create.mako" /> -${parent.body()} diff --git a/tailbone/templates/newbatch/edit.mako b/tailbone/templates/newbatch/edit.mako deleted file mode 100644 index f448e085..00000000 --- a/tailbone/templates/newbatch/edit.mako +++ /dev/null @@ -1,66 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/edit.mako" /> - -<%def name="head_tags()"> - ${parent.head_tags()} - ${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 else 'false'}; - - $(function() { - - $('#save-refresh').click(function() { - var form = $(this).parents('form'); - form.append($('<input type="hidden" name="refresh" value="true" />')); - form.submit(); - }); - - }); - </script> - <style type="text/css"> - - .newgrid-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('{}.rows.bulk_delete'.format(route_prefix), uuid=batch.uuid))}</p> - % endif -</%def> - -<ul id="context-menu"> - ${self.context_menu_items()} -</ul> - -<div class="form-wrapper"> - ${form.render(buttons=capture(buttons))|n} -</div><!-- form-wrapper --> - -${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.grid_tools))|n} - -<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: - ${rendered_execution_options|n} - % endif - ${h.end_form()} - -</div> diff --git a/tailbone/templates/newbatch/index.mako b/tailbone/templates/newbatch/index.mako deleted file mode 100644 index 2380ecea..00000000 --- a/tailbone/templates/newbatch/index.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/index.mako" /> -${parent.body()} diff --git a/tailbone/templates/newbatch/view.mako b/tailbone/templates/newbatch/view.mako deleted file mode 100644 index 0dce344b..00000000 --- a/tailbone/templates/newbatch/view.mako +++ /dev/null @@ -1,56 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/view.mako" /> - -<%def name="head_tags()"> - ${parent.head_tags()} - ${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 else 'false'}; - </script> - <style type="text/css"> - - .newgrid-wrapper { - margin-top: 10px; - } - - </style> -</%def> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if request.has_perm('{}.csv'.format(permission_prefix)): - <li>${h.link_to("Download row data as CSV", url('{}.csv'.format(route_prefix), uuid=batch.uuid))}</li> - % endif -</%def> - -<%def name="buttons()"> - <div class="buttons"> - % if not form.readonly and batch.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> - -<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} - -<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: - ${rendered_execution_options|n} - % endif - ${h.end_form()} - -</div> diff --git a/tailbone/templates/newgrids/complete.mako b/tailbone/templates/newgrids/complete.mako deleted file mode 100644 index 86e9ee90..00000000 --- a/tailbone/templates/newgrids/complete.mako +++ /dev/null @@ -1,38 +0,0 @@ -## -*- coding: utf-8 -*- -<div class="newgrid-wrapper"> - - <table class="grid-header"> - <tbody> - <tr> - - <td class="filters" rowspan="2"> - % if grid.filterable: - ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n} - % endif - </td> - - <td class="menu"> - % if context_menu: - <ul id="context-menu"> - ${context_menu|n} - </ul> - % endif - </td> - </tr> - - <tr> - <td class="tools"> - % if tools: - <div class="grid-tools"> - ${tools|n} - </div><!-- grid-tools --> - % endif - </td> - </tr> - - </tbody> - </table><!-- grid-header --> - - ${grid.render_grid()|n} - -</div><!-- newgrid-wrapper --> diff --git a/tailbone/templates/newgrids/filters.mako b/tailbone/templates/newgrids/filters.mako deleted file mode 100644 index b841e52c..00000000 --- a/tailbone/templates/newgrids/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/newgrids/grid.mako b/tailbone/templates/newgrids/grid.mako deleted file mode 100644 index 61b02a47..00000000 --- a/tailbone/templates/newgrids/grid.mako +++ /dev/null @@ -1,50 +0,0 @@ -## -*- coding: utf-8 -*- -<div ${format_attrs(**grid.get_div_attrs())}> - <table> - <thead> - <tr> - % if grid.checkboxes: - <th class="checkbox">${h.checkbox('check-all')}</th> - % endif - % for column in grid.iter_visible_columns(): - ${grid.column_header(column)} - % endfor - % if grid.show_actions_column: - <th class="actions">Actions</th> - % endif - </tr> - </thead> - <tbody> - % for i, row in enumerate(grid.iter_rows(), 1): - <tr ${format_attrs(**grid.get_row_attrs(row, i))}> - % if grid.checkboxes: - <td class="checkbox">${grid.render_checkbox(row)}</td> - % endif - % for column in grid.iter_visible_columns(): - <td ${format_attrs(**grid.get_cell_attrs(row, column))}>${grid.render_cell(row, column)}</td> - % endfor - % if grid.show_actions_column: - <td class="actions"> - ${grid.render_actions(row, i)} - </td> - % endif - </tr> - % endfor - </tbody> - </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', partial=1)} - </p> - </div> - % endif -</div> 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 new file mode 100644 index 00000000..8f2d5e27 --- /dev/null +++ b/tailbone/templates/ordering/create.mako @@ -0,0 +1,6 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/create.mako" /> + +## TODO: deprecate / remove + +${parent.body()} diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako new file mode 100644 index 00000000..34a6085f --- /dev/null +++ b/tailbone/templates/ordering/view.mako @@ -0,0 +1,418 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/view.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + ${h.stylesheet_link(request.static_url('tailbone:static/css/purchases.css'))} +</%def> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('{}.download_excel'.format(permission_prefix)): + <li>${h.link_to("Download {} as Excel".format(model_title), url('{}.download_excel'.format(route_prefix), uuid=batch.uuid))}</li> + % endif +</%def> + +<%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> + +<%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/worksheet.mako b/tailbone/templates/ordering/worksheet.mako new file mode 100644 index 00000000..eb2077e7 --- /dev/null +++ b/tailbone/templates/ordering/worksheet.mako @@ -0,0 +1,304 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/worksheet.mako" /> + +<%def name="title()">Ordering Worksheet</%def> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + .order-form th.department { + border-top: 1px solid black; + font-size: 1.2em; + padding: 15px; + text-align: left; + text-transform: uppercase; + } + + .order-form th.subdepartment { + background-color: white; + border-bottom: 1px solid black; + border-top: 1px solid black; + padding: 15px; + text-align: left; + } + + .order-form tr.active { + border: 5px solid Blue; + } + + .order-form td { + border-right: 1px solid #000000; + border-top: 1px solid #000000; + } + + .order-form td.upc, + .order-form td.case-qty, + .order-form td.code, + .order-form td.preferred, + .order-form td.scratch_pad { + text-align: center; + } + + .order-form td.scratch_pad { + width: 40px; + } + + .order-form td.current-order input { + text-align: center; + width: 3em; + } + + .order-form td.unit-cost, + .order-form td.po-total { + text-align: right; + } + + </style> +</%def> + +<%def name="context_menu_items()"> + <li>${h.link_to("Back to {}".format(model_title), url('ordering.view', uuid=batch.uuid))}</li> +</%def> + +<%def name="extra_vendor_fields()"></%def> + +<%def name="extra_count()">0</%def> + +<%def name="extra_th()"></%def> + +<%def name="extra_td(cost)"></%def> + +<%def name="order_form_grid()"> + <div class="grid"> + <table class="order-form"> + <% column_count = 8 + len(header_columns) + (0 if ignore_cases else 1) + int(capture(self.extra_count)) %> + % for department in sorted(departments.values(), key=lambda d: d.name if d else ''): + <thead> + <tr> + <th class="department" colspan="${column_count}">Department + % if department.number or department.name: + ${department.number} ${department.name} + % else: + (N/A) + % endif + </th> + </tr> + % for subdepartment in sorted(department._order_subdepartments.values(), key=lambda s: s.name if s else ''): + <tr> + <th class="subdepartment" colspan="${column_count}">Subdepartment + % if subdepartment.number or subdepartment.name: + ${subdepartment.number} ${subdepartment.name} + % else: + (N/A) + % endif + </th> + </tr> + <tr> + % for title in header_columns: + <th>${title}</th> + % endfor + % for data in history: + <th> + % if data: + % if data['purchase']['date_received']: + Rec.<br /> + ${data['purchase']['date_received'].strftime('%m/%d')} + % elif data['purchase']['date_ordered']: + Ord.<br /> + ${data['purchase']['date_ordered'].strftime('%m/%d')} + % else: + ?? + % endif + % endif + </th> + % endfor + % if not ignore_cases: + <th> + ${order_date.strftime('%m/%d')}<br /> + Cases + </th> + % endif + <th> + ${order_date.strftime('%m/%d')}<br /> + Units + </th> + <th>PO Total</th> + ${self.extra_th()} + </tr> + </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'}" + :class="{active: activeUUID == '${cost.uuid}'}"> + ${self.order_form_row(cost)} + % for data in history: + <td class="scratch_pad"> + % if data: + <% item = data['items'].get(cost.product_uuid) %> + % if item: + % if ignore_cases: + % if item['units_received'] is not None: + ${int(item['units_received'] or 0)} + % elif item['units_ordered'] is not None: + ${int(item['units_ordered'] or 0)} + % endif + % else: + % if item['cases_received'] is not None or item['units_received'] is not None: + ${'{} / {}'.format(int(item['cases_received'] or 0), int(item['units_received'] or 0))} + % elif item['cases_ordered'] is not None or item['units_ordered'] is not None: + ${'{} / {}'.format(int(item['cases_ordered'] or 0), int(item['units_ordered'] or 0))} + % endif + % endif + % endif + % endif + </td> + % endfor + % if not ignore_cases: + <td class="current-order"> + <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"> + <numeric-input v-model="worksheet.cost_${cost.uuid}_units" + @focus="activeUUID = '${cost.uuid}'; $event.target.select()" + @blur="activeUUID = null" + @keydown.native="inputKeydown($event, '${cost.uuid}', '${cost.product_uuid}')"> + </numeric-input> + </td> + <td class="po-total">{{ worksheet.cost_${cost.uuid}_total_display }}</td> + ${self.extra_td(cost)} + </tr> + % endfor + </tbody> + % endfor + % endfor + </table> + </div> +</%def> + +<%def name="order_form_row(cost)"> + <td class="upc">${get_upc(cost.product)}</td> + <td class="brand">${cost.product.brand or ''}</td> + <td class="desc">${cost.product.description} ${cost.product.size or ''}</td> + <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"> + % 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 new file mode 100644 index 00000000..15c669fa --- /dev/null +++ b/tailbone/templates/people/view.mako @@ -0,0 +1,41 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> +<%namespace file="/util.mako" import="view_profiles_helper" /> + +<%def name="page_content()"> + ${parent.page_content()} + % if not instance.users and request.has_perm('users.create'): + ${h.form(url('people.make_user'), ref='makeUserForm')} + ${h.csrf_token(request)} + ${h.hidden('person_uuid', value=instance.uuid)} + ${h.end_form()} + % endif +</%def> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + ${view_profiles_helper([instance])} +</%def> + +<%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 new file mode 100644 index 00000000..ddc44e3d --- /dev/null +++ b/tailbone/templates/principal/find_by_perm.mako @@ -0,0 +1,246 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="title()">Find ${model_title_plural} by Permission</%def> + +<%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"> + + 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}, + } + }, + + computed: { + + 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> + +<%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 new file mode 100644 index 00000000..fa806455 --- /dev/null +++ b/tailbone/templates/principal/index.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % 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> + +${parent.body()} diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index ee44d607..db029e5a 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -1,28 +1,117 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> -<%def name="title()">Create Products Batch</%def> +<%def name="title()">Create Batch</%def> <%def name="context_menu_items()"> <li>${h.link_to("Back to Products", url('products'))}</li> </%def> -<div class="form"> +<%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> - ${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)} - <div class="field-wrapper"> - <label for="provider">Batch Type</label> - <div class="field"> - ${h.select('provider', None, providers)} - </div> - </div> + <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 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"> - ${h.submit('create', "Create Batch")} - ${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()} +</%def> -</div> +<%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/edit.mako b/tailbone/templates/products/edit.mako deleted file mode 100644 index 981d42ab..00000000 --- a/tailbone/templates/products/edit.mako +++ /dev/null @@ -1,11 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/edit.mako" /> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if version_count is not Undefined and request.has_perm('product.versions.view'): - <li>${h.link_to("View Change History ({})".format(version_count), url('product.versions', uuid=instance.uuid))}</li> - % endif -</%def> - -${parent.body()} diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index f241eefa..5ffa9512 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -1,99 +1,85 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="head_tags()"> - ${parent.head_tags()} - <style type="text/css"> +<%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> - table.label-printing th { - font-weight: normal; - padding: 0px 0px 2px 4px; - text-align: left; - } +<%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> - table.label-printing td { - padding: 0px 0px 0px 4px; - } +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if label_profiles and master.has_perm('print_labels'): + <script> - table.label-printing #label-quantity { - text-align: right; - width: 30px; - } + ${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} - 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> - % if label_profiles and request.has_perm('products.print_labels'): - <script type="text/javascript"> + ${grid.vue_component}.methods.quickLabelPrint = function(row) { - $(function() { + let quantity = parseInt(this.quickLabelQuantity) + if (isNaN(quantity)) { + alert("You must provide a valid label quantity.") + this.$refs.quickLabelQuantityInput.focus() + return + } - $('.newgrid-wrapper .grid-header .tools select').selectmenu(); + if (this.quickLabelSpeedbumpThreshold && quantity >= this.quickLabelSpeedbumpThreshold) { + if (!confirm("Are you sure you want to print " + quantity + " labels?")) { + return + } + } - $('.newgrid-wrapper').on('click', 'a.print_label', function() { - 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: get_uuid(this), - profile: $('#label-profile').val(), - quantity: quantity - }; - console.log(data); - $.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; - }); - }); + 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/versions/index.mako b/tailbone/templates/products/versions/index.mako deleted file mode 100644 index d26bebd4..00000000 --- a/tailbone/templates/products/versions/index.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/index.mako" /> -${parent.body()} diff --git a/tailbone/templates/products/versions/view.mako b/tailbone/templates/products/versions/view.mako deleted file mode 100644 index 94567acb..00000000 --- a/tailbone/templates/products/versions/view.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/view.mako" /> -${parent.body()} diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 11f54655..66ca3128 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -1,177 +1,413 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%namespace file="/forms/lib.mako" import="render_field_readonly" /> -<% product = instance %> - -<%def name="head_tags()"> - ${parent.head_tags()} +<%def name="extra_styles()"> + ${parent.extra_styles()} <style type="text/css"> - #product-main { - width: 80%; + nav.item-panel { + min-width: 600px; } - #product-image { - float: left; - margin-left: 300px; + #main-product-panel { + margin-right: 2em; + margin-top: 1em; } - .panel-wrapper { - float: left; - margin-right: 15px; - min-width: 40%; + #pricing-panel .field-wrapper .field { + white-space: nowrap; } </style> </%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if version_count is not Undefined and request.has_perm('product.versions.view'): - <li>${h.link_to("View Change History ({})".format(version_count), url('product.versions', uuid=product.uuid))}</li> - % endif +<%def name="render_main_fields(form)"> + % for field in panel_fields['main']: + ${form.render_field_readonly(field)} + % endfor + ${self.extra_main_fields(form)} </%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)} -</%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)} -</%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.deleted)} -</%def> - -<%def name="render_movement_fields(form)"> - ${render_field_readonly(form.fieldset.last_sold)} -</%def> - -<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;"> - ${render_field_readonly(form.fieldset.upc)} - ${render_field_readonly(form.fieldset.brand)} - ${render_field_readonly(form.fieldset.description)} - ${render_field_readonly(form.fieldset.unit_size)} - ${render_field_readonly(form.fieldset.unit_of_measure)} - ${render_field_readonly(form.fieldset.size)} - ${render_field_readonly(form.fieldset.case_pack)} - </div> - % if image: - ${h.image(image_url, "Product Image", id='product-image', path=image_path, use_pil=False)} - % endif - </div> - </div> - - <div class="panel-wrapper"> <!-- left column --> - - <div class="panel"> - <h2>Pricing</h2> - <div class="panel-body"> +<%def name="left_column()"> + <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 class="panel"> - <h2>Flags</h2> - <div class="panel-body"> + </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> + </nav> + ${self.extra_left_panels()} +</%def> - ${self.extra_left_panels()} +<%def name="right_column()"> + ${self.organization_panel()} + ${self.movement_panel()} + ${self.sources_panel()} + ${self.notes_panel()} + ${self.ingredients_panel()} + ${self.lookup_codes_panel()} + ${self.extra_right_panels()} +</%def> - </div> <!-- left column --> +<%def name="extra_main_fields(form)"></%def> - <div class="panel-wrapper"> <!-- right column --> - - <div class="panel"> - <h2>Organization</h2> - <div class="panel-body"> +<%def name="organization_panel()"> + <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> + </nav> +</%def> - <div class="panel"> - <h2>Movement</h2> - <div class="panel-body"> +<%def name="render_organization_fields(form)"> + ${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)"> + ${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)"> + % for field in panel_fields['flag']: + ${form.render_field_readonly(field)} + % endfor +</%def> + +<%def name="movement_panel()"> + <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> + </nav> +</%def> - <div class="panel-grid" id="product-costs"> - <h2>Vendor Sources</h2> - <div class="grid full hoverable 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> - </thead> - <tbody> - % for i, cost in enumerate(product.costs, 1): - <tr class="${'odd' if i % 2 else 'even'}"> - <td class="center">${'X' if cost.preference == 1 else ''}</td> - <td>${cost.vendor}</td> - <td class="center">${cost.code}</td> - <td class="center">${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> - </tr> - % endfor - </tbody> - </table> - </div> +<%def name="render_movement_fields(form)"> + ${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()"> + <nav class="panel item-panel"> + <p class="panel-heading">Additional Lookup Codes</p> + <div class="panel-block"> + ${self.lookup_codes_grid()} </div> + </nav> +</%def> - <div class="panel-grid" id="product-codes"> - <h2>Additional Lookup Codes</h2> - <div class="grid full hoverable no-border"> - <table> - <thead> - <th>Code</th> - </thead> - <tbody> - % for i, code in enumerate(product.codes, 1): - <tr class="${'odd' if i % 2 else 'even'}"> - <td>${code}</td> - </tr> - % endfor - </tbody> - </table> - </div> +<%def name="sources_grid()"> + ${vendor_sources['grid'].render_table_element(data_prop='vendorSourcesData')|n} +</%def> + +<%def name="sources_panel()"> + <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> + </nav> +</%def> - ${self.extra_right_panels()} +<%def name="notes_panel()"> + <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> + </nav> +</%def> - </div> <!-- right column --> - - % if buttons: - ${buttons|n} - % endif -</div> +<%def name="ingredients_panel()"> + <nav class="panel item-panel"> + <p class="panel-heading">Ingredients</p> + <div class="panel-block"> + ${form.render_field_readonly('ingredients')} + </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 2d33e8bc..ad0a1371 100644 --- a/tailbone/templates/progress.mako +++ b/tailbone/templates/progress.mako @@ -1,146 +1,219 @@ -## -*- coding: utf-8 -*- -<!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"> +## -*- coding: utf-8; -*- +<%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>Working...</title> - ${h.javascript_link('https://code.jquery.com/jquery-1.11.3.min.js')} - ${h.javascript_link('https://code.jquery.com/ui/1.11.4/jquery-ui.min.js')} - ${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.menubar.js'))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/base.css'))} - ${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css'))} - <style type="text/css"> + <title>${initial_msg or "Working"}...</title> + ${core_javascript()} + ${core_styles()} + ${self.extra_styles()} + </head> + <body style="height: 100%;"> - #body-wrapper { - position: relative; - } + <div id="whole-page-app"> + <whole-page></whole-page> + </div> - #wrapper { - height: 60px; - left: 50%; - margin-top: -45px; - margin-left: -350px; - position: absolute; - top: 50%; - width: 700px; - } + <script type="text/x-template" id="whole-page-template"> - #progress-wrapper { - border-collapse: collapse; - } + <section class="hero is-fullheight"> + <div class="hero-body"> + <div class="container"> - #progress { - border-collapse: collapse; - height: 25px; - width: 550px; - } + <div style="display: flex;"> + <div style="flex-grow: 1;"></div> + <div> - #complete { - background-color: Gray; - width: 0px; - } + <p class="block"> + {{ progressMessage }} ... {{ totalDisplay }} + </p> - #remaining { - background-color: LightGray; - width: 100%; - } + <div class="level"> - #percentage { - padding-left: 3px; - min-width: 50px; - width: 50px; - } + <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> - #cancel { - min-width: 200px; - } + % 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 - </style> - <script language="javascript" type="text/javascript"> + </div> - var updater = null; + </div> + <div style="flex-grow: 1;"></div> + </div> - function update_progress() { - $.ajax({ - url: '${url('progress', key=key)}', - success: function(data) { - if (data.error) { - location.href = '${cancel_url}'; - } else if (data.complete || data.maximum) { - $('#message').html(data.message); - $('#total').html('('+data.maximum+' 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 > 0) { - $('#complete').css('width', width+'%'); - $('#remaining').css('width', 'auto'); - } - $('#percentage').html(width+'%'); - } + ${self.after_progress()} + + </div> + </div> + </section> + + </script> + + <script type="text/javascript"> + + let WholePage = { + template: '#whole-page-template', + + computed: { + + totalDisplay() { + + % if can_cancel: + if (!this.stillInProgress && !this.cancelingProgress) { + % else: + if (!this.stillInProgress) { + % endif + return "done!" + } + + 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 + } } - updater = setInterval(function() {update_progress()}, 1000); + let WholePageData = { - $(function() { - $('#cancel button').click(function() { - if (confirm("Do you really wish to cancel this operation?")) { - clearInterval(updater); - disable_button(this, "Cancelling"); - $.ajax({ - url: '${url('progress.cancel', key=key)}', - data: { - 'cancel_msg': '${cancel_msg}', - }, - success: function(data) { - location.href = '${cancel_url}'; - }, - }); - } - }); - }); + 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, - </script> - </head> - <body> - <div id="body-wrapper"> + % if can_cancel: + canCancel: true, + cancelURL: '${url('progress.cancel', key=progress.key, _query={'sessiontype': progress.session.type})}', + cancelingProgress: false, + % endif + } - <div id="wrapper"> + </script> - <p><span id="message">${initial_msg or "Working"} (please wait)</span> ... <span id="total"></span></p> + ${self.modify_whole_page_vars()} + ${self.make_whole_page_app()} - <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 --> - - </div><!-- #body-wrapper --> </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 new file mode 100644 index 00000000..35ee878a --- /dev/null +++ b/tailbone/templates/purchases/batches/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/purchases/batches/index.mako b/tailbone/templates/purchases/batches/index.mako new file mode 100644 index 00000000..4290f93b --- /dev/null +++ b/tailbone/templates/purchases/batches/index.mako @@ -0,0 +1,11 @@ +## -*- coding: utf-8; -*- +<%inherit file="/batch/index.mako" /> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if request.has_perm('purchases.batch'): + <li>${h.link_to("Go to Purchases", url('purchases'))}</li> + % endif +</%def> + +${parent.body()} diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako new file mode 100644 index 00000000..94028bdb --- /dev/null +++ b/tailbone/templates/purchases/credits/index.mako @@ -0,0 +1,82 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="grid_tools()"> + ${parent.grid_tools()} + + <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> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + ${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 + + ${grid.vue_component}.methods.changeStatusInit = function() { + this.changeStatusValue = null + this.changeStatusShowDialog = true + } + + ${grid.vue_component}.methods.changeStatusSubmit = function() { + this.changeStatusSubmitting = true + this.$refs.changeStatusForm.submit() + } + + </script> +</%def> diff --git a/tailbone/templates/purchases/receiving_worksheet.mako b/tailbone/templates/purchases/receiving_worksheet.mako new file mode 100644 index 00000000..596bfd43 --- /dev/null +++ b/tailbone/templates/purchases/receiving_worksheet.mako @@ -0,0 +1,93 @@ +## -*- coding: utf-8; -*- +<html> + <head> + <title>Receiving Worksheet</title> + <style type="text/css"> + .notes { + margin-bottom: 2em; + } + .spacer { + display: inline-block; + width: 1em; + } + .receiving-info { + padding-top: 1em; + } + table { + font-size: 0.8em; + white-space: nowrap; + } + th, td { + padding: 1em 0.4em 0 0; + } + th { + text-align: left; + } + .quantity { + text-align: center; + } + .currency { + text-align: right; + } + </style> + </head> + <body> + + <h1>Receiving Worksheet</h1> + + <p class="notes">Notes:</p> + + <p class="info"> + Vendor: <strong>(${purchase.vendor.id}) ${purchase.vendor}</strong> + <span class="spacer"></span> + Phone: <strong>${purchase.vendor.phone}</strong> + </p> + <p class="info"> + Contact: <strong>${purchase.vendor.contact}</strong> + <span class="spacer"></span> + Fax: <strong>${purchase.vendor.fax_number}</strong> + </p> + <p class="info"> + Store ID: <strong>${purchase.store.id}</strong> + <span class="spacer"></span> + Buyer: <strong>${purchase.buyer}</strong> + <span class="spacer"></span> + Order Date: <strong>${purchase.date_ordered}</strong> + </p> + <p class="receiving-info"> + Received on (date): ____________________ + <span class="spacer"></span> + Received by (name): ____________________ + </p> + + <table> + <thead> + <tr> + <th>UPC</th> + <th>Vend Code</th> + <th>Brand</th> + <th>Description</th> + <th>Cases</th> + <th>Units</th> + <th>Unit Cost</th> + <th>Total Cost</th> + </tr> + </thead> + <tbody> + % for item in purchase.items: + <tr> + <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> + <td class="quantity">${h.pretty_quantity(item.cases_ordered or 0)}</td> + <td class="quantity">${h.pretty_quantity(item.units_ordered or 0)}</td> + <td class="currency">${'${:0,.2f}'.format(item.po_unit_cost) if item.po_unit_cost is not None else ''}</td> + <td class="currency">${'${:0,.2f}'.format(item.po_total) if item.po_total is not None else ''}</td> + </tr> + % endfor + </tbody> + </table> + + </body> +</html> diff --git a/tailbone/templates/purchases/view.mako b/tailbone/templates/purchases/view.mako new file mode 100644 index 00000000..74871427 --- /dev/null +++ b/tailbone/templates/purchases/view.mako @@ -0,0 +1,16 @@ +## -*- coding: utf-8 -*- +<%inherit file="/master/view.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + ${h.stylesheet_link(request.static_url('tailbone:static/css/purchases.css'))} +</%def> + +<%def name="context_menu_items()"> + ${parent.context_menu_items()} + % if instance.status < enum.PURCHASE_STATUS_RECEIVED and request.has_perm('purchases.receiving_worksheet'): + <li>${h.link_to("Print Receiving Worksheet", url('purchases.receiving_worksheet', uuid=instance.uuid), target='_blank')}</li> + % endif +</%def> + +${parent.body()} 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 b8148bb1..cc5adc10 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -1,34 +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())} + <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 54d1cb08..e77cca33 100644 --- a/tailbone/templates/roles/edit.mako +++ b/tailbone/templates/roles/edit.mako @@ -1,16 +1,16 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/edit.mako" /> -<%def name="head_tags()"> - ${parent.head_tags()} +<%def name="extra_styles()"> + ${parent.extra_styles()} ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if version_count is not Undefined and request.has_perm('role.versions.view'): - <li>${h.link_to("View Change History ({0})".format(version_count), url('role.versions', uuid=instance.uuid))}</li> - % endif +<%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> - -${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/versions/index.mako b/tailbone/templates/roles/versions/index.mako deleted file mode 100644 index d26bebd4..00000000 --- a/tailbone/templates/roles/versions/index.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/index.mako" /> -${parent.body()} diff --git a/tailbone/templates/roles/versions/view.mako b/tailbone/templates/roles/versions/view.mako deleted file mode 100644 index 94567acb..00000000 --- a/tailbone/templates/roles/versions/view.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/view.mako" /> -${parent.body()} diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index e1f77f3f..f5588695 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -1,29 +1,25 @@ -## -*- 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()} ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if version_count is not Undefined and request.has_perm('role.versions.view'): - <li>${h.link_to("View Change History ({0})".format(version_count), url('role.versions', uuid=instance.uuid))}</li> - % endif +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + + % if users_data is not Undefined: + ${form.vue_component}Data.usersData = ${json.dumps(users_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> - -${parent.body()} - -<h2>Users</h2> - -% if instance is guest_role: - <p>The guest role is implied for all anonymous users, i.e. when not logged in.</p> -% elif instance is authenticated_role: - <p>The authenticated role is implied for all users, but only when logged in.</p> -% elif users: - <p>The following users are assigned to this role:</p> - ${users.render_grid()|n} -% else: - <p>There are no users assigned to this role.</p> -% endif 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 c5427547..52b48832 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -1,173 +1,223 @@ -## -*- 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="head_tags()"> - ${parent.head_tags()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/timesheet.css'))} - <script type="text/javascript"> +<%def name="extra_styles()"> + ${parent.extra_styles()} + ${h.stylesheet_link(request.static_url('tailbone:static/css/timesheet.css'))} +</%def> - function employee_selected(uuid, name) { - $('.timesheet-wrapper form').submit(); - } - - $(function() { - - $('.timesheet-wrapper form').submit(function() { - $('.timesheet-header').mask("Fetching data"); - }); - - $('.timesheet-header select').selectmenu({ - change: function(event, ui) { - $(ui.item.element).parents('form').submit(); - } - }); - - $('.timesheet-header a.goto').click(function() { - $('.timesheet-header').mask("Fetching data"); - }); - - $('.week-picker button.nav').click(function() { - $('.week-picker #date').val($(this).data('date')); - }); - - $('.week-picker #date').datepicker({ - dateFormat: 'mm/dd/yy', - changeYear: true, - changeMonth: true, - showButtonPanel: true, - onSelect: function(dateText, inst) { - $(this).parents('form').submit(); - } - }); - - }); - - </script> +<%def name="edit_timetable_styles()"> + <style type="text/css"> + .timesheet .day { + cursor: pointer; + height: 5em; + } + .timesheet tr .day.modified { + background-color: #fcc; + } + .timesheet tr:nth-child(odd) .day.modified { + background-color: #ebb; + } + .timesheet .day .shift.deleted { + display: none; + } + #day-editor .shift { + margin-bottom: 1em; + white-space: nowrap; + } + #day-editor .shift input { + width: 6em; + } + #day-editor .shift button { + margin-left: 0.5em; + } + #snippets { + display: none; + } + </style> </%def> <%def name="context_menu()"></%def> -<%def name="timesheet()"> - <style type="text/css"> - .timesheet thead th { - width: ${'{:0.2f}'.format(100.0 / 9)}%; - } - </style> +<%def name="timesheet_wrapper(with_edit_form=False, change_employee=None)"> + <div class="timesheet-wrapper"> - <div class="timesheet-wrapper"> + ${h.form(request.current_route_url(_query=False), id='filter-form')} + ${h.csrf_token(request)} - ${form.begin()} + <table class="timesheet-header"> + <tbody> + <tr> - <table class="timesheet-header"> - <tbody> - <tr> + <td class="filters" rowspan="2"> - <td class="filters" rowspan="2"> + % if employee is not Undefined: + <div class="field-wrapper employee"> + <label>Employee</label> + <div class="field"> + ${dform['employee'].serialize(text=str(employee), selected_callback='employee_selected')|n} + </div> + </div> + % endif - % if employee is not UNDEFINED: - <div class="field-wrapper employee"> - <label>Employee</label> + % if store_options is not Undefined: + <div class="field-wrapper store"> + <div class="field-row"> + <label for="store">Store</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')} - % else: - ${form.hidden('employee', value=employee.uuid)} - ${employee} - % endif + ${dform['store'].serialize()|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))} - % endif - - % if department_options is not UNDEFINED: - ${form.field_div('department', h.select('department', department.uuid if department else None, department_options))} - % endif - - <div class="field-wrapper week"> - <label>Week of</label> - <div class="field"> - ${week_of} </div> + % endif + + % if department_options is not Undefined: + <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"> + <label>Week of</label> + <div class="field"> + ${week_of} </div> + </div> - </td><!-- filters --> + ${self.edit_tools()} - <td class="menu"> - <ul id="context-menu"> - ${self.context_menu()} - </ul> - </td><!-- menu --> - </tr> + </td><!-- filters --> - <tr> - <td class="tools"> - <div class="grid-tools"> - <div class="week-picker"> - <button class="nav" data-date="${prev_sunday.strftime('%m/%d/%Y')}">« Previous</button> - <button class="nav" data-date="${next_sunday.strftime('%m/%d/%Y')}">Next »</button> - <label>Jump to week:</label> - ${form.text('date', value=sunday.strftime('%m/%d/%Y'))} - </div> - </div><!-- grid-tools --> - </td><!-- tools --> - </tr> + <td class="menu"> + <ul id="context-menu"> + ${self.context_menu()} + </ul> + </td><!-- menu --> + </tr> - </tbody> - </table><!-- timesheet-header --> + <tr> + <td class="tools"> + <div class="grid-tools"> + <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> + ${dform['date'].serialize(extra_options={'showButtonPanel': True}, selected_callback='date_selected')|n} + </div> + </div><!-- grid-tools --> + </td><!-- tools --> + </tr> - ${form.end()} + </tbody> + </table><!-- timesheet-header --> - <table class="timesheet"> - <thead> - <tr> - <th>Employee</th> - % for day in weekdays: - <th>${day.strftime('%A')}<br />${day.strftime('%b %d')}</th> - % endfor - <th>Total<br />Hours</th> - </tr> - </thead> - <tbody> - % for emp in sorted(employees, key=unicode): - <tr> - <td class="employee">${emp}</td> - % for day in emp.weekdays: - <td> - % for shift in day['shifts']: - <p class="shift">${render_shift(shift)}</p> - % endfor - </td> - % endfor - <td>${emp.hours_display}</td> - </tr> - % endfor - % if employee is UNDEFINED: - <tr class="total"> - <td class="employee">${len(employees)} employees</td> - % for day in weekdays: - <td></td> - % endfor - <td></td> - </tr> - % else: - <tr> - <td> </td> - % for day in employee.weekdays: - <td>${day['hours_display']}</td> - % endfor - <td>${employee.hours_display}</td> - </tr> - % endif - </tbody> - </table> - </div><!-- timesheet-wrapper --> + ${h.end_form()} + + % if with_edit_form: + ${self.edit_form()} + % endif + + ${self.timesheet()} + + % if with_edit_form: + ${h.end_form()} + % endif + + </div><!-- timesheet-wrapper --> </%def> + +<%def name="timesheet(render_day=None, extra_columns=0)"> + <style type="text/css"> + .timesheet thead th { + width: ${'{:0.2f}'.format(100.0 / (9 + extra_columns))}%; + } + </style> + + <table class="timesheet"> + <thead> + <tr> + <th>Employee</th> + % for day in weekdays: + <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=str): + <tr data-employee-uuid="${emp.uuid}"> + <td class="employee"> + ## TODO: add link to single employee schedule / timesheet here... + ${emp} + </td> + % for day in emp.weekdays: + <td class="day"> + % if render_day: + ${render_day(day)} + % else: + ${self.render_day(day)} + % endif + </td> + % endfor + <td class="total"> + ${self.render_employee_total(emp)} + </td> + ${self.render_extra_cells(employee)} + </tr> + % endfor + % if employee is Undefined: + <tr class="total"> + <td class="employee">${len(employees)} employees</td> + % for day in weekdays: + <td></td> + % endfor + <td></td> + </tr> + % else: + <tr> + <td> </td> + % for day in employee.weekdays: + <td> + ${self.render_employee_day_total(day)} + </td> + % endfor + <td> + ${self.render_employee_total(employee)} + </td> + ${self.render_extra_totals(employee)} + </tr> + % endif + </tbody> + </table> +</%def> + +<%def name="edit_form()"></%def> + +<%def name="edit_tools()"></%def> + +<%def name="render_day(day)"></%def> + +<%def name="render_employee_total(employee)"></%def> + +<%def name="render_employee_day_total(day)"></%def> + +<%def name="render_extra_headers()"></%def> + +<%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 420c2ceb..ec2a136c 100644 --- a/tailbone/templates/shifts/schedule.mako +++ b/tailbone/templates/shifts/schedule.mako @@ -2,11 +2,34 @@ <%inherit file="/shifts/base.mako" /> <%def name="context_menu()"> - % if request.has_perm('timesheet.view'): - <li>${h.link_to("View this Time Sheet", url('schedule.goto.timesheet'), class_='goto')}</li> - % endif -## <li>${h.link_to("Print this Schedule", '#')}</li> -## <li>${h.link_to("Edit this Schedule", '#')}</li> + % if request.has_perm('schedule.edit'): + <li>${h.link_to("Edit Schedule", url('schedule.edit'))}</li> + % endif + % if request.has_perm('schedule.print'): + % 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> + % endif </%def> -${self.timesheet()} +<%def name="render_day(day)"> + % for shift in day['scheduled_shifts']: + <p class="shift">${render_shift(shift)}</p> + % endfor +</%def> + +<%def name="render_employee_total(employee)"> + ${employee.scheduled_hours_display} +</%def> + +<%def name="render_employee_day_total(day)"> + ${day['scheduled_hours_display']} +</%def> + + +${parent.body()} diff --git a/tailbone/templates/shifts/schedule_edit.mako b/tailbone/templates/shifts/schedule_edit.mako new file mode 100644 index 00000000..4455c74d --- /dev/null +++ b/tailbone/templates/shifts/schedule_edit.mako @@ -0,0 +1,92 @@ +## -*- coding: utf-8; -*- +<%inherit file="/shifts/base.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + ${self.edit_timetable_styles()} +</%def> + +<%def name="context_menu()"> + % if request.has_perm('schedule.viewall'): + <li>${h.link_to("View Schedule", url('schedule'))}</li> + % endif + % if request.has_perm('schedule.print'): + <li>${h.link_to("Print Schedule", url('schedule.print'), target='_blank')}</li> + % endif +</%def> + +<%def name="render_day(day)"> + % for shift in day['scheduled_shifts']: + <p class="shift" data-uuid="${shift.uuid}"> + ${render_shift(shift)} + </p> + % endfor +</%def> + +<%def name="render_employee_total(employee)"> + ${employee.scheduled_hours_display} +</%def> + +<%def name="edit_form()"> + ${h.form(url('schedule.edit'), id='timetable-form')} + ${h.csrf_token(request)} +</%def> + +<%def name="edit_tools()"> + <div class="buttons"> + <button type="button" class="save-changes" disabled="disabled">Save Changes</button> + <button type="button" class="undo-changes" disabled="disabled">Undo Changes</button> + % if allow_clear: + <button type="button" class="clear-schedule">Clear Schedule</button> + % endif + <button type="button" class="copy-schedule">Copy Schedule From...</button> + </div> +</%def> + +<%def name="page_content()"> + + ${self.timesheet_wrapper(with_edit_form=True)} + + ${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')} + ${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.mako b/tailbone/templates/shifts/schedule_print.mako new file mode 100644 index 00000000..f7284bc8 --- /dev/null +++ b/tailbone/templates/shifts/schedule_print.mako @@ -0,0 +1,21 @@ +## -*- 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> + ${store if store else "(all stores)"} - + ${department if department else "(all departments)"} - + ${week_of} + </h1> + ${timesheet(render_day=render_day)} + </body> +</html> 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 0dfee396..b93de6ac 100644 --- a/tailbone/templates/shifts/timesheet.mako +++ b/tailbone/templates/shifts/timesheet.mako @@ -1,10 +1,28 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/shifts/base.mako" /> <%def name="context_menu()"> + % if employee is not Undefined and request.has_perm('timesheet.edit'): + <li>${h.link_to("Edit this Time Sheet", url('timesheet.employee.edit'))}</li> + % endif % if request.has_perm('schedule.view'): <li>${h.link_to("View this Schedule", url('timesheet.goto.schedule'), class_='goto')}</li> % endif </%def> -${self.timesheet()} +<%def name="render_day(day)"> + % for shift in day['worked_shifts']: + <p class="shift">${render_shift(shift)}</p> + % endfor +</%def> + +<%def name="render_employee_total(employee)"> + ${employee.worked_hours_display} +</%def> + +<%def name="render_employee_day_total(day)"> + <span title="${h.hours_as_decimal(day['worked_hours'])} hrs">${day['worked_hours_display']}</span> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/shifts/timesheet_edit.mako b/tailbone/templates/shifts/timesheet_edit.mako new file mode 100644 index 00000000..c4dc7a6b --- /dev/null +++ b/tailbone/templates/shifts/timesheet_edit.mako @@ -0,0 +1,58 @@ +## -*- coding: utf-8; -*- +<%inherit file="/shifts/base.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + ${self.edit_timetable_styles()} +</%def> + +<%def name="context_menu()"> + % if request.has_perm('timesheet.view'): + <li>${h.link_to("View this Time Sheet", url('timesheet.employee'))}</li> + % endif + % if request.has_perm('schedule.view'): + <li>${h.link_to("View this Schedule", url('schedule.employee'))}</li> + % endif +</%def> + +<%def name="render_day(day)"> + % for shift in day['worked_shifts']: + <p class="shift" data-uuid="${shift.uuid}"> + ${render_shift(shift)} + </p> + % endfor +</%def> + +<%def name="render_employee_total(employee)"> + ${employee.worked_hours_display} +</%def> + +<%def name="render_employee_day_total(day)"> + ${day['worked_hours_display']} +</%def> + +<%def name="edit_form()"> + ${h.form(url('timesheet.employee.edit'), id='timetable-form')} + ${h.csrf_token(request)} +</%def> + +<%def name="page_content()"> + + ${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> + </div> + +</%def> + + +${parent.body()} diff --git a/tailbone/templates/subdepartments/versions/index.mako b/tailbone/templates/subdepartments/versions/index.mako deleted file mode 100644 index d26bebd4..00000000 --- a/tailbone/templates/subdepartments/versions/index.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/index.mako" /> -${parent.body()} diff --git a/tailbone/templates/subdepartments/versions/view.mako b/tailbone/templates/subdepartments/versions/view.mako deleted file mode 100644 index 94567acb..00000000 --- a/tailbone/templates/subdepartments/versions/view.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/view.mako" /> -${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 new file mode 100644 index 00000000..434da4c8 --- /dev/null +++ b/tailbone/templates/tempmon/clients/view.mako @@ -0,0 +1,30 @@ +## -*- 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="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> + +<%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/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/better/base.mako b/tailbone/templates/themes/better/base.mako deleted file mode 100644 index 57fcc3bc..00000000 --- a/tailbone/templates/themes/better/base.mako +++ /dev/null @@ -1,132 +0,0 @@ -## -*- coding: utf-8 -*- -<%namespace name="base" file="tailbone:templates/base.mako" /> -<%namespace file="/menu.mako" import="main_menu_items" /> -<%namespace file="/newgrids/nav.mako" import="grid_index_nav" /> -<!DOCTYPE html> -<html lang="en"> - <head> - <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> - <title>${self.global_title()} » ${capture(self.title)}</title> - ${self.favicon()} - ${base.core_javascript()} - ${self.extra_javascript()} - ${base.core_styles(jquery_theme=self.jquery_theme)} - ${self.extra_styles()} - - % if not request.rattail_config.production(): - <style type="text/css"> - body { background-image: url(${request.static_url('tailbone:static/img/testing.png')}); } - </style> - % endif - - ${self.head_tags()} - </head> - - <body> - <div id="body-wrapper"> - - <header> - <nav> - <ul class="menubar"> - ${main_menu_items()} - </ul> - </nav> - - <div class="global"> - <a class="home" href="${url('home')}"> - ${self.header_logo()} - <span>${self.global_title()}</span> - </a> - % if master: - <span class="global">»</span> - % if master.listing: - <span class="global">${model_title_plural}</span> - % else: - ${h.link_to(index_title, index_url, class_='global')} - % if master.viewing and grid_index: - ${grid_index_nav()} - % endif - % endif - % endif - - <div class="feedback"> - ${h.link_to("Feedback", url('feedback'), class_='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 --> - - </body> -</html> - -<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}Rattail</%def> - -<%def name="content_title()"> - <h1>${self.title()}</h1> -</%def> - -<%def name="favicon()"></%def> - -<%def name="jquery_theme()"> - ${h.stylesheet_link('https://code.jquery.com/ui/1.11.4/themes/dark-hive/jquery-ui.css')} -</%def> - -<%def name="extra_javascript()"></%def> - -<%def name="extra_styles()"> - ${h.stylesheet_link(request.static_url('tailbone:static/css/theme-better.css'))} -</%def> - -<%def name="head_tags()"></%def> - -<%def name="header_logo()"></%def> - -<%def name="footer()"> - powered by ${h.link_to("Rattail {}".format(rattail.__version__), 'https://rattailproject.org/', target='_blank')} -</%def> diff --git a/tailbone/templates/themes/better/master/create.mako b/tailbone/templates/themes/better/master/create.mako deleted file mode 100644 index 9070c58e..00000000 --- a/tailbone/templates/themes/better/master/create.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="tailbone:templates/master/create.mako" /> - -<%def name="context_menu_items()"></%def> - -${parent.body()} diff --git a/tailbone/templates/themes/better/master/edit.mako b/tailbone/templates/themes/better/master/edit.mako deleted file mode 100644 index 00803375..00000000 --- a/tailbone/templates/themes/better/master/edit.mako +++ /dev/null @@ -1,18 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="tailbone:templates/master/edit.mako" /> - -<%def name="title()">Edit: ${instance_title}</%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> - -${parent.body()} diff --git a/tailbone/templates/themes/better/master/index.mako b/tailbone/templates/themes/better/master/index.mako deleted file mode 100644 index b1d1d798..00000000 --- a/tailbone/templates/themes/better/master/index.mako +++ /dev/null @@ -1,6 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="tailbone:templates/master/index.mako" /> - -<%def name="content_title()"></%def> - -${parent.body()} diff --git a/tailbone/templates/themes/better/master/view.mako b/tailbone/templates/themes/better/master/view.mako deleted file mode 100644 index fb98b980..00000000 --- a/tailbone/templates/themes/better/master/view.mako +++ /dev/null @@ -1,21 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="tailbone:templates/master/view.mako" /> - -<%def name="content_title()"> - <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.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 -</%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 new file mode 100644 index 00000000..7cc73941 --- /dev/null +++ b/tailbone/templates/upgrade.mako @@ -0,0 +1,69 @@ +## -*- coding: utf-8; -*- +<%inherit file="/progress.mako" /> + +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style type="text/css"> + + .progress-with-textout { + border: 1px solid Black; + line-height: 1.2; + margin-top: 1rem; + overflow: auto; + padding: 1rem; + } + + </style> +</%def> + +<%def name="after_progress()"> + <!-- <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 new file mode 100644 index 00000000..c3fca81d --- /dev/null +++ b/tailbone/templates/upgrades/view.mako @@ -0,0 +1,289 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%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> + +<%def name="render_this_page()"> + ${parent.render_this_page()} + + % if expose_websockets and master.has_perm('execute'): + <${b}-modal full-screen + % if request.use_oruga: + v-model:active="upgradeExecuting" + :cancelable="false" + % else: + :active.sync="upgradeExecuting" + :can-cancel="false" + % endif + > + <div class="card"> + <div class="card-content"> + + <div class="level"> + <div class="level-item has-text-centered" + style="display: flex; flex-direction: column;"> + <p class="block"> + Upgrading ${system_title} (please wait) ... + {{ executeUpgradeComplete ? "DONE!" : "" }} + </p> + % if request.use_oruga: + <progress class="progress is-large" + style="width: 400px;" /> + % else: + <b-progress size="is-large" + style="width: 400px;" + ## :value="80" + ## show-value + ## format="percent" + > + </b-progress> + % endif + </div> + <div class="level-right"> + <div class="level-item"> + <b-button type="is-warning" + icon-pack="fas" + icon-left="sad-tear" + @click="declareFailureClick()"> + Declare Failure + </b-button> + </div> + </div> + </div> + + <div class="container progress-with-textout is-family-monospace is-size-7" + ref="textout"> + <span v-for="line in progressOutput" + :key="line.key" + v-html="line.text"> + </span> + + ## nb. we auto-scroll down to "see" this element + <div ref="seeme"></div> + </div> + + </div> + </div> + </${b}-modal> + % endif + + % if master.has_perm('execute'): + ${h.form(master.get_action_url('declare_failure', instance), ref='declareFailureForm')} + ${h.csrf_token(request)} + ${h.end_form()} + % endif +</%def> + +<%def name="render_form()"> + <div class="form"> + <${form.component} + % if master.has_perm('execute'): + @declare-failure-click="declareFailureClick" + :declare-failure-submitting="declareFailureSubmitting" + % if expose_websockets: + % if instance_executable: + @execute-upgrade-click="executeUpgrade" + % endif + :upgrade-executing="upgradeExecuting" + % endif + % endif + > + </${form.component}> + </div> +</%def> + +<%def name="render_form_buttons()"> + % if 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> diff --git a/tailbone/templates/users/edit.mako b/tailbone/templates/users/edit.mako deleted file mode 100644 index 3e7334c5..00000000 --- a/tailbone/templates/users/edit.mako +++ /dev/null @@ -1,11 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/master/edit.mako" /> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if version_count is not Undefined and request.has_perm('user.versions.view'): - <li>${h.link_to("View Change History ({0})".format(version_count), url('user.versions', uuid=instance.uuid))}</li> - % endif -</%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/versions/index.mako b/tailbone/templates/users/versions/index.mako deleted file mode 100644 index d26bebd4..00000000 --- a/tailbone/templates/users/versions/index.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/index.mako" /> -${parent.body()} diff --git a/tailbone/templates/users/versions/view.mako b/tailbone/templates/users/versions/view.mako deleted file mode 100644 index 94567acb..00000000 --- a/tailbone/templates/users/versions/view.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/view.mako" /> -${parent.body()} diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index be6d4800..d1afd218 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -1,16 +1,136 @@ -## -*- coding: utf-8 -*- +## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> +<%namespace file="/util.mako" import="view_profiles_helper" /> -<%def name="head_tags()"> - ${parent.head_tags()} +<%def name="extra_styles()"> + ${parent.extra_styles()} ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="context_menu_items()"> - ${parent.context_menu_items()} - % if version_count is not Undefined and request.has_perm('user.versions.view'): - <li>${h.link_to("View Change History ({0})".format(version_count), url('user.versions', uuid=instance.uuid))}</li> +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if instance.person: + ${view_profiles_helper([instance.person])} % endif </%def> -${parent.body()} +<%def name="render_this_page()"> + ${parent.render_this_page()} + + % if master.has_perm('manage_api_tokens'): + + <b-modal :active.sync="apiNewTokenShowDialog" + has-modal-card> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + New API Token + </p> + </header> + <section class="modal-card-body"> + + <div v-if="!apiNewTokenSaved"> + <b-field label="Description" + :type="{'is-danger': !apiNewTokenDescription}"> + <b-input v-model.trim="apiNewTokenDescription" + expanded + ref="apiNewTokenDescription"> + </b-input> + </b-field> + </div> + + <div v-if="apiNewTokenSaved"> + <p class="block"> + Your new API token is shown below. + </p> + <p class="block"> + IMPORTANT: You must record this token elsewhere + for later reference. You will NOT be able to + recover the value if you lose it. + </p> + <b-field horizontal label="API Token"> + {{ apiNewTokenRaw }} + </b-field> + <b-field horizontal label="Description"> + {{ apiNewTokenDescription }} + </b-field> + </div> + + </section> + <footer class="modal-card-foot"> + <b-button @click="apiNewTokenShowDialog = false"> + {{ apiNewTokenSaved ? "Close" : "Cancel" }} + </b-button> + <b-button v-if="!apiNewTokenSaved" + type="is-primary" + icon-pack="fas" + icon-left="save" + @click="apiNewTokenSave()" + :disabled="!apiNewTokenDescription || apiNewTokenSaving"> + Save + </b-button> + </footer> + </div> + </b-modal> + + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if master.has_perm('manage_api_tokens'): + <script> + + ${form.vue_component}.props.apiTokens = null + + ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n} + + ThisPageData.apiNewTokenShowDialog = false + ThisPageData.apiNewTokenDescription = null + + ThisPage.methods.apiNewToken = function() { + this.apiNewTokenDescription = null + this.apiNewTokenSaved = false + this.apiNewTokenShowDialog = true + this.$nextTick(() => { + this.$refs.apiNewTokenDescription.focus() + }) + } + + ThisPageData.apiNewTokenSaving = false + ThisPageData.apiNewTokenSaved = false + ThisPageData.apiNewTokenRaw = null + + ThisPage.methods.apiNewTokenSave = function() { + this.apiNewTokenSaving = true + + let url = '${master.get_action_url('add_api_token', instance)}' + let params = { + description: this.apiNewTokenDescription, + } + + this.simplePOST(url, params, response => { + this.apiTokens = response.data.tokens + this.apiNewTokenSaving = false + this.apiNewTokenRaw = response.data.raw_token + this.apiNewTokenSaved = true + }, response => { + this.apiNewTokenSaving = false + }) + } + + ThisPage.methods.apiTokenDelete = function(token) { + if (!confirm("Really delete this API token?")) { + return + } + + let url = '${master.get_action_url('delete_api_token', instance)}' + let params = {uuid: token.uuid} + this.simplePOST(url, params, response => { + this.apiTokens = response.data.tokens + }) + } + + </script> + % endif +</%def> diff --git a/tailbone/templates/util.mako b/tailbone/templates/util.mako 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 8565a547..00000000 --- a/tailbone/templates/vendors/catalogs/create.mako +++ /dev/null @@ -1,55 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/newbatch/create.mako" /> - -<%def name="head_tags()"> - ${parent.head_tags()} - <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/vendors/versions/index.mako b/tailbone/templates/vendors/versions/index.mako deleted file mode 100644 index d26bebd4..00000000 --- a/tailbone/templates/vendors/versions/index.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/index.mako" /> -${parent.body()} diff --git a/tailbone/templates/vendors/versions/view.mako b/tailbone/templates/vendors/versions/view.mako deleted file mode 100644 index 94567acb..00000000 --- a/tailbone/templates/vendors/versions/view.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/versions/view.mako" /> -${parent.body()} diff --git a/tailbone/templates/versions/index.mako b/tailbone/templates/versions/index.mako deleted file mode 100644 index be7d956e..00000000 --- a/tailbone/templates/versions/index.mako +++ /dev/null @@ -1,15 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/grid.mako" /> - -<%def name="title()">${model_title} Change History</%def> - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to all {0}".format(model_title_plural), url(route_model_list))}</li> - <li>${h.link_to("Back to current {0}".format(model_title), url(route_model_view, uuid=model_instance.uuid))}</li> -</%def> - -<%def name="form()"> - <h2>Changes for ${model_title}: ${model_instance}</h2> -</%def> - -${parent.body()} diff --git a/tailbone/templates/versions/view.mako b/tailbone/templates/versions/view.mako deleted file mode 100644 index 49d7c4e5..00000000 --- a/tailbone/templates/versions/view.mako +++ /dev/null @@ -1,103 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="/base.mako" /> - -<%def name="title()">${model_title} Version Details</%def> - -<%def name="head_tags()"> - <style type="text/css"> - td.oldvalue { - background-color: #fcc; - } - td.newvalue { - background-color: #cfc; - } - </style> -</%def> - -<%def name="context_menu_items()"> - <li>${h.link_to("Back to all {0}".format(model_title_plural), url(route_model_list))}</li> - <li>${h.link_to("Back to current {0}".format(model_title), url(route_model_view, uuid=model_instance.uuid))}</li> - <li>${h.link_to("Back to Version History", url('{0}.versions'.format(route_prefix), uuid=model_instance.uuid))}</li> -</%def> - -<div class="form-wrapper"> - - <ul class="context-menu"> - ${self.context_menu_items()} - </ul> - - <div class="form"> - - <div> - % if previous_transaction or next_transaction: - % if previous_transaction: - ${h.link_to("<< older version", url('{0}.version'.format(route_prefix), uuid=model_instance.uuid, transaction_id=previous_transaction.id))} - % else: - <span>(oldest version)</span> - % endif - | - % if next_transaction: - ${h.link_to("newer version >>", url('{0}.version'.format(route_prefix), uuid=model_instance.uuid, transaction_id=next_transaction.id))} - % else: - <span>(newest version)</span> - % endif - % else: - <span>(only version)</span> - % endif - </div> - - <div class="fieldset"> - - <div class="field-wrapper"> - <label>When:</label> - <div class="field">${h.pretty_datetime(request.rattail_config, transaction.issued_at)}</div> - </div> - <div class="field-wrapper"> - <label>Who:</label> - <div class="field">${transaction.user or "(unknown / system)"}</div> - </div> - <div class="field-wrapper"> - <label>Where:</label> - <div class="field">${transaction.remote_addr}</div> - </div> - - % for ver in versions: - - <div class="field-wrapper"> - <label>What:</label> - <div class="field" style="font-weight: bold;">${ver.version_parent.__class__.__name__}: ${ver.version_parent}</div> - </div> - - <div class="field-wrapper"> - <label>Changes:</label> - <div class="field"> - <div class="grid"> - <table> - <thead> - <tr> - <th>Field</th> - <th>Old Value</th> - <th>New Value</th> - </tr> - </thead> - <tbody> - % for key in sorted(ver.changeset): - <tr> - <td>${key}</td> - <td class="oldvalue">${ver.changeset[key][0]}</td> - <td class="newvalue">${ver.changeset[key][1]}</td> - </tr> - % endfor - </tbody> - </table> - </div> - </div> - </div> - - % endfor - - </div> - - </div> - -</div> 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 new file mode 100644 index 00000000..9c06c1be --- /dev/null +++ b/tailbone/tweens.py @@ -0,0 +1,71 @@ +# -*- 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/>. +# +################################################################################ +""" +Tween Factories +""" + +from sqlalchemy.exc import OperationalError + + +def sqlerror_tween_factory(handler, registry): + """ + Produces a tween which will convert ``sqlalchemy.exc.OperationalError`` + 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 ``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: + 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 c1a486d6..71aa35e3 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -1,39 +1,135 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Utilities """ -from __future__ import unicode_literals, absolute_import - import datetime +import importlib +import logging +import warnings -import pytz import humanize +import markdown -from webhelpers.html import HTML +from rattail.files import resource_path -from rattail.time import timezone, make_utc +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): + """ + DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()` + instead. + """ + 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): @@ -49,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', @@ -66,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. @@ -79,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 3c155780..29c73b61 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -1,62 +1,42 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ - """ Pyramid Views """ +from __future__ import unicode_literals, absolute_import + from .core import View -from tailbone.views.grids import ( - GridView, AlchemyGridView, SortableAlchemyGridView, - PagedAlchemyGridView, SearchableAlchemyGridView) -from .crud import * -from .master import MasterView -from tailbone.views.autocomplete import AutocompleteView - - -def home(request): - """ - Default home view. - """ - - return {} - - -def add_routes(config): - config.add_route('home', '/') +from .master import MasterView, ViewSupplement def includeme(config): - add_routes(config) - config.add_view(home, route_name='home', - renderer='/home.mako') - - config.include('tailbone.views.core') + # core views config.include('tailbone.views.common') - config.include('tailbone.views.auth') - config.include('tailbone.views.batches') + + # main table views config.include('tailbone.views.bouncer') config.include('tailbone.views.brands') config.include('tailbone.views.categories') @@ -65,17 +45,30 @@ def includeme(config): config.include('tailbone.views.datasync') config.include('tailbone.views.departments') config.include('tailbone.views.depositlinks') + config.include('tailbone.views.email') config.include('tailbone.views.employees') config.include('tailbone.views.families') + config.include('tailbone.views.handheld') + config.include('tailbone.views.inventory') config.include('tailbone.views.labels') + config.include('tailbone.views.messages') config.include('tailbone.views.people') config.include('tailbone.views.products') config.include('tailbone.views.progress') - config.include(u'tailbone.views.reportcodes') + config.include('tailbone.views.purchases') + config.include('tailbone.views.reportcodes') + config.include('tailbone.views.reports') config.include('tailbone.views.roles') + config.include('tailbone.views.settings') config.include('tailbone.views.shifts') config.include('tailbone.views.stores') config.include('tailbone.views.subdepartments') config.include('tailbone.views.taxes') + config.include('tailbone.views.upgrades') config.include('tailbone.views.users') 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 a8d103e8..eceab803 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -1,165 +1,247 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Auth Views """ -from __future__ import unicode_literals +import colander +from deform import widget as dfwidget +from pyramid.httpexceptions import HTTPForbidden +from webhelpers2.html import tags, literal -from pyramid.httpexceptions import HTTPFound -from pyramid.security import remember, forget, authenticated_userid - -from webhelpers.html import literal -from webhelpers.html import tags - -import formencode -from pyramid_simpleform import Form -from ..forms.simpleform import FormRenderer - -from ..db import Session -from rattail.db.auth import authenticate_user, set_user_password +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 -def forbidden(request): - """ - Access forbidden view. +class UserLogin(colander.MappingSchema): - This is triggered whenever access is not allowed for an otherwise - appropriate view. - """ - msg = literal("You do not have permission to do that.") - if not authenticated_userid(request): - msg += literal(" (Perhaps you should %s?)" % - tags.link_to("log in", request.route_url('login'))) - # Store current URL in session, for smarter redirect after login. - request.session['next_url'] = request.current_route_url() - request.session.flash(msg, allow_duplicate=False) - return HTTPFound(location=request.get_referrer()) + username = colander.SchemaNode(colander.String()) + + password = colander.SchemaNode(colander.String(), + widget=dfwidget.PasswordWidget()) -class UserLogin(formencode.Schema): - allow_extra_fields = True - filter_extra_fields = True - username = formencode.validators.NotEmpty() - password = formencode.validators.NotEmpty() +class AuthenticationView(View): + + def forbidden(self): + """ + Access forbidden view. + + This is triggered whenever access is not allowed for an otherwise + appropriate view. + """ + next_url = self.request.get_referrer() + msg = literal("You do not have permission to do that.") + if not self.request.authenticated_userid: + msg += literal(" (Perhaps you should %s?)" % + tags.link_to("log in", self.request.route_url('login'))) + # 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, 'warning', allow_duplicate=False) + return self.redirect(next_url) + + def login(self, **kwargs): + """ + The login view, responsible for displaying and handling the login form. + """ + app = self.get_rattail_app() + referrer = self.request.get_referrer(default=self.request.route_url('home')) + + # redirect if already logged in + if self.request.user: + self.request.session.flash("{} is already logged in".format(self.request.user), 'error') + return self.redirect(referrer) + + 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.validated['username'], + form.validated['password']) + if user: + # okay now they're truly logged in + headers = login_user(self.request, user) + # treat URL from session as referrer, if available + referrer = self.request.session.pop('next_url', referrer) + return self.redirect(referrer, headers=headers) + + else: + self.request.session.flash("Invalid username or password", 'error') + + # 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': form, + 'referrer': referrer, + 'index_title': app.get_node_title(), + 'help_url': global_help_url(self.rattail_config), + } + + def authenticate_user(self, username, password): + app = self.get_rattail_app() + auth = app.get_auth_handler() + return auth.authenticate_user(Session(), username, password) + + 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) + + # 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): + """ + View to serve as "no-op" / ping action to reset current user's session timer + """ + return {'status': 'ok'} + + def change_password(self): + """ + Allows a user to change his or her password. + """ + if not self.request.user: + return self.redirect(self.request.route_url('home')) + + 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()) + + def check_user_password(node, value): + auth = self.app.get_auth_handler() + user = self.request.user + if not auth.check_user_password(user, value): + node.raise_invalid("The password is incorrect") + + schema = colander.Schema() + + schema.add(colander.SchemaNode(colander.String(), + name='current_password', + widget=dfwidget.PasswordWidget(), + validator=check_user_password)) + + schema.add(colander.SchemaNode(colander.String(), + name='new_password', + widget=dfwidget.CheckedPasswordWidget())) + + form = forms.Form(schema=schema, request=self.request) + if form.validate(): + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, form.validated['new_password']) + self.request.session.flash("Your password has been changed.") + return self.redirect(self.request.get_referrer()) + + return {'index_title': str(self.request.user), + 'form': form} + + def become_root(self): + """ + Elevate the current request to 'root' for full system access. + """ + if not self.request.is_admin: + raise HTTPForbidden() + self.request.user.record_event(self.enum.USER_EVENT_BECOME_ROOT) + self.request.session['is_root'] = True + self.request.session.flash("You have been elevated to 'root' and now have full system access") + return self.redirect(self.request.get_referrer()) + + def stop_root(self): + """ + Lower the current request from 'root' back to normal access. + """ + if not self.request.is_admin: + raise HTTPForbidden() + self.request.user.record_event(self.enum.USER_EVENT_STOP_ROOT) + self.request.session['is_root'] = False + self.request.session.flash("Your normal system access has been restored") + return self.redirect(self.request.get_referrer()) + + @classmethod + def defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + + # forbidden + config.add_forbidden_view(cls, attr='forbidden') + + # login + config.add_route('login', '/login') + config.add_view(cls, attr='login', route_name='login', renderer='/login.mako') + + # logout + config.add_route('logout', '/logout') + config.add_view(cls, attr='logout', route_name='logout') + + # no-op + config.add_route('noop', '/noop') + config.add_view(cls, attr='noop', route_name='noop', renderer='json') + + # change password + config.add_route('change_password', '/change-password') + 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 login(request): - """ - The login view, responsible for displaying and handling the login form. - """ - referrer = request.get_referrer() +def defaults(config, **kwargs): + base = globals() - # Redirect if already logged in. - if request.user: - return HTTPFound(location=referrer) + AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView']) + AuthenticationView.defaults(config) - form = Form(request, schema=UserLogin) - if form.validate(): - user = authenticate_user(Session(), - form.data['username'], - form.data['password']) - if user: - headers = remember(request, user.uuid) - # Treat URL from session as referrer, if available. - referrer = request.session.pop('next_url', referrer) - return HTTPFound(location=referrer, headers=headers) - request.session.flash("Invalid username or password") - - return {'form': FormRenderer(form), 'referrer': referrer} - - -def logout(request): - """ - View responsible for logging out the current user. - - This deletes/invalidates the current session and then redirects to the - login page. - """ - - request.session.delete() - request.session.invalidate() - headers = forget(request) - referrer = request.get_referrer() - return HTTPFound(location=referrer, headers=headers) - - -class CurrentPasswordCorrect(formencode.validators.FancyValidator): - - def _to_python(self, value, state): - user = state - if not authenticate_user(Session, user.username, value): - raise formencode.Invalid("The password is incorrect.", value, state) - return value - - -class ChangePassword(formencode.Schema): - - allow_extra_fields = True - filter_extra_fields = True - - current_password = formencode.All( - formencode.validators.NotEmpty(), - CurrentPasswordCorrect()) - - new_password = formencode.validators.NotEmpty() - confirm_password = formencode.validators.NotEmpty() - - chained_validators = [formencode.validators.FieldsMatch( - 'new_password', 'confirm_password')] - - -def change_password(request): - """ - Allows a user to change his or her password. - """ - - if not request.user: - return HTTPFound(location=request.route_url('home')) - - form = Form(request, schema=ChangePassword, state=request.user) - if form.validate(): - set_user_password(request.user, form.data['new_password']) - return HTTPFound(location=request.get_referrer()) - - return {'form': FormRenderer(form)} - - -def add_routes(config): - config.add_route('login', '/login') - config.add_route('logout', '/logout') - config.add_route('change_password', '/change-password') - def includeme(config): - add_routes(config) - - config.add_forbidden_view(forbidden) - - config.add_view(login, route_name='login', - renderer='/login.mako') - - config.add_view(logout, route_name='logout') - - config.add_view(change_password, route_name='change_password', - renderer='/change_password.mako') + defaults(config) diff --git a/tailbone/views/autocomplete.py b/tailbone/views/autocomplete.py deleted file mode 100644 index 5dbf2f35..00000000 --- a/tailbone/views/autocomplete.py +++ /dev/null @@ -1,80 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -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.py b/tailbone/views/batch.py deleted file mode 100644 index b8f56b91..00000000 --- a/tailbone/views/batch.py +++ /dev/null @@ -1,2044 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Base views for maintaining "new-style" batches. - -.. note:: - This is all still somewhat experimental. -""" - -from __future__ import unicode_literals, absolute_import - -import os -import datetime -import logging -from cStringIO import StringIO - -from sqlalchemy import orm - -from rattail.db import model, Session as RattailSession -from rattail.threads import Thread -from rattail.csvutil import UnicodeDictWriter -from rattail.util import load_object - -import formalchemy -from pyramid import httpexceptions -from pyramid.renderers import render_to_response -from pyramid.response import FileResponse -from pyramid_simpleform import Form -from webhelpers.html import HTML - -from tailbone import forms, newgrids as grids -from tailbone.db import Session -from tailbone.views import MasterView, SearchableAlchemyGridView, CrudView -from tailbone.forms.renderers.batch import FileFieldRenderer -from tailbone.grids.search import BooleanSearchFilter, EnumSearchFilter -from tailbone.progress import SessionProgress - - -log = logging.getLogger(__name__) - - -class BatchMasterView(MasterView): - """ - Base class for all "batch master" views. - """ - has_rows = True - rows_deletable = True - refreshable = True - - def __init__(self, request): - super(BatchMasterView, self).__init__(request) - self.handler = self.get_handler() - - def get_handler(self): - """ - 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: - - .. 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. - """ - key = self.model_class.batch_key - spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key)) - if spec: - return load_object(spec)(self.rattail_config) - return self.batch_handler_class(self.rattail_config) - - def template_kwargs_view(self, **kwargs): - batch = kwargs['instance'] - kwargs['batch'] = batch - kwargs['handler'] = self.handler - kwargs['execute_title'] = self.get_execute_title(batch) - kwargs['execute_enabled'] = self.executable(batch) - if kwargs['execute_enabled'] and self.has_execution_options: - kwargs['rendered_execution_options'] = self.render_execution_options(batch) - return kwargs - - def render_execution_options(self, batch): - 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): - return kwargs - - def get_instance_title(self, batch): - return batch.id_str or unicode(batch) - - def _preconfigure_grid(self, g): - """ - Apply some commonly-useful pre-configuration to the main batch grid. - """ - 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' - - g.sorters['created_by'] = g.make_sorter(model.User.username) - g.sorters['executed_by'] = g.make_sorter(model.User.username) - - g.default_sortkey = 'created' - g.default_sortdir = 'desc' - - g.id.set(label="ID", renderer=forms.renderers.BatchIDFieldRenderer) - g.created_by.set(label="Created by", renderer=forms.renderers.UserFieldRenderer) - g.cognized_by.set(renderer=forms.renderers.UserFieldRenderer) - g.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer) - - def configure_grid(self, g): - """ - Apply final configuration to the main batch grid. Custom batch views - are encouraged to override this method. - """ - g.configure( - include=[ - g.id, - g.created, - g.created_by, - g.executed, - g.executed_by, - ], - readonly=True) - - 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.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer) - - 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. - """ - if self.creating: - fs.configure( - include=[ - fs.created_by.hidden(), - ]) - - 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, - ]) - - def _postconfigure_fieldset(self, fs): - if self.creating: - if 'created' in fs.render_fields: - del fs.created - if 'created_by' in fs.render_fields: - del fs.created_by - if 'executed' in fs.render_fields: - del fs.executed - if 'executed_by' in fs.render_fields: - del fs.executed_by - else: - batch = fs.model - if not batch.executed: - if 'executed' in fs.render_fields: - del fs.executed - if 'executed_by' in fs.render_fields: - del fs.executed_by - - def save_create_form(self, form): - """ - Save the uploaded data file if necessary, etc. If batch initialization - fails, don't persist the batch at all; the user will be sent back to - the "create batch" page in that case. - """ - self.before_create(form) - - # Transfer form data to batch instance. - form.fieldset.sync() - batch = form.fieldset.model - - # Assign current user as creator. - with Session.no_autoflush: - batch.created_by = self.request.user or self.late_login_user() - - # TODO: Wouldn't this be handled sufficiently by `no_autoflush` ? - # Expunge batch from session to prevent it from being flushed - # during init. This is done as a convenience to views which - # provide an init method. Some batches may have required fields - # which aren't filled in yet, but the view may need to query the - # database to obtain the values. This will cause a session flush, - # and the missing fields will trigger data integrity errors. - Session.expunge(batch) - - self.batch_inited = self.init_batch(batch) - if self.batch_inited: - Session.add(batch) - Session.flush() - - else: # batch init failed - - # Here we assume that the :meth:`init_batch()` method responsible - # for indicating the failure will have set a flash message for the - # user with more info. - raise self.redirect(self.request.current_route_url()) - - def init_batch(self, batch): - """ - Initialize a new batch. Derived classes can override this to - effectively provide default values for a batch, etc. This method is - invoked after a batch has been fully prepared for insertion to the - database, but before the push to the database occurs. - - Note that the return value of this function matters; if it is boolean - false then the batch will not be persisted at all, and the user will be - redirected to the "create batch" page. - """ - return True - - # 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 batch.executed: - return self.redirect(self.get_action_url('view', batch)) - - grid = self.make_row_grid(batch=batch) - - # 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': - if form.validate(): - self.save_edit_form(form) - self.request.session.flash("{0} {1} has been updated.".format( - self.get_model_title(), self.get_instance_title(batch))) - return self.redirect_after_edit(batch) - - context = { - 'instance': batch, - 'instance_title': self.get_instance_title(batch), - 'instance_deletable': self.deletable_instance(batch), - 'form': form, - 'batch': batch, - 'rows_grid': grid, - 'execute_title': self.get_execute_title(batch), - 'execute_enabled': self.executable(batch), - } - - if context['execute_enabled'] and self.has_execution_options: - context['rendered_execution_options'] = self.render_execution_options(batch) - - return self.render_to_response('edit', context) - - def redirect_after_edit(self, batch): - """ - Redirect back to edit batch page after editing a batch, unless the - refresh flag is set, in which case do that. - """ - if self.request.params.get('refresh') == 'true': - return self.redirect(self.get_action_url('refresh', batch)) - return self.redirect(self.request.current_route_url()) - - 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) - - def get_fallback_templates(self, template): - return [ - '/newbatch/{}.mako'.format(template), - '/master/{}.mako'.format(template), - ] - - def editable_instance(self, batch): - return not bool(batch.executed) - - def executable(self, batch): - return self.handler.executable(batch) - - @property - def has_execution_options(self): - return bool(self.execution_options_schema) - - # TODO - execution_options_schema = None - - def make_execution_options_form(self, batch): - """ - Return a proper Form for execution options. - """ - 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) - - def get_execute_title(self, batch): - return self.handler.get_execute_title(batch) - - def refresh(self): - """ - View which will attempt to refresh all data for the batch. What - 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() - - 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 - - # If handler doesn't declare the need for progress indicator, things - # are nice and simple. - if not self.handler.show_progress: - 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 = { - 'key': key, - '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(kwargs) - - def refresh_data(self, session, batch, cognizer=None, progress=None): - """ - Instruct the batch handler to refresh all data for the batch. - """ - self.handler.refresh_data(session, batch, progress=progress) - batch.cognized = datetime.datetime.utcnow() - batch.cognized_by = cognizer or session.merge(self.request.user) - - def refresh_thread(self, batch_uuid, progress=None, cognizer_uuid=None, success_url=None): - """ - 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 - try: - self.refresh_data(session, batch, cognizer=cognizer, progress=progress) - except Exception as error: - session.rollback() - log.warning("refreshing data for batch failed: {}".format(batch), exc_info=True) - session.close() - if progress: - progress.session.load() - progress.session['error'] = True - progress.session['error_msg'] = "Data refresh failed: {}".format(error) - progress.session.save() - return - - session.commit() - session.refresh(batch) - session.close() - - # Finalize progress indicator. - if progress: - progress.session.load() - progress.session['complete'] = True - progress.session['success_url'] = success_url or self.get_action_url('view', batch) - progress.session.save() - - ######################################## - # batch rows - ######################################## - - def get_row_instance_title(self, row): - return "Row {}".format(row.sequence) - - def _preconfigure_row_grid(self, g): - - g.filters['status_code'].label = "Status" - g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) - - g.default_sortkey = 'sequence' - - g.sequence.set(label="Seq.") - g.status_code.set(label="Status", - renderer=StatusRenderer(self.model_row_class.STATUS)) - - def get_row_data(self, batch): - """ - Generate the base data set for a rows grid. - """ - return self.Session.query(self.model_row_class)\ - .filter(self.model_row_class.batch == batch)\ - .filter(self.model_row_class.removed == False) - - def row_editable(self, row): - """ - Batch rows are editable only until batch has been executed. - """ - return self.rows_editable and not row.batch.executed - - 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): - """ - Batch rows are deletable only until batch has been executed. - """ - return self.rows_deletable and not row.batch.executed - - def row_delete_action_url(self, row, i): - if self.row_deletable(row): - return self.get_row_action_url('delete', row) - - 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) - except AttributeError: - pass - - def configure_row_fieldset(self, fs): - fs.configure() - del fs.batch - - def template_kwargs_view_row(self, **kwargs): - kwargs['batch_model_title'] = kwargs['parent_model_title'] - return kwargs - - def get_parent(self, row): - return row.batch - - def delete_row(self): - """ - "Delete" a row from the batch. This sets the ``removed`` flag on the - row but does not truly delete it. - """ - row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid']) - if not row: - raise httpexceptions.HTTPNotFound() - row.removed = True - return self.redirect(self.get_action_url('view', self.get_parent(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_query() - query.update({'removed': True}, synchronize_session=False) - return self.redirect(self.get_action_url('view', self.get_instance())) - - def execute(self): - """ - Execute a batch. Starts a separate thread for the execution, and - displays a progress indicator page. - """ - batch = self.get_instance() - if self.request.method == 'POST': - - kwargs = {} - if self.has_execution_options: - 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) - - key = '{}.execute'.format(self.model_class.__tablename__) - kwargs['progress'] = SessionProgress(self.request, key) - thread = Thread(target=self.execute_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs) - thread.start() - - return self.render_progress({ - 'key': key, - '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') - return self.redirect(self.get_action_url('view', batch)) - - def execute_thread(self, batch_uuid, user_uuid, progress=None, **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) - try: - result = self.handler.execute(batch, user=user, progress=progress, **kwargs) - - # If anything goes wrong, rollback and log the error etc. - except Exception as error: - session.rollback() - log.exception("execution failed for batch: {}".format(batch)) - session.close() - if progress: - progress.session.load() - progress.session['error'] = True - progress.session['error_msg'] = "Batch execution failed: {}".format(error) - progress.session.save() - - # If no error, check result flag (false means user canceled). - else: - 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)) - else: - session.rollback() - - session.refresh(batch) - success_url = self.get_execute_success_url(batch, result, **kwargs) - session.close() - - if progress: - progress.session.load() - progress.session['complete'] = True - progress.session['success_url'] = success_url - progress.session.save() - - def get_execute_success_url(self, batch, result, **kwargs): - return self.get_action_url('view', batch) - - def csv(self): - """ - Download batch data as CSV. - """ - batch = self.get_instance() - fields = self.get_csv_fields() - data = StringIO() - writer = UnicodeDictWriter(data, fields) - writer.writeheader() - for row in batch.data_rows: - if not row.removed: - writer.writerow(self.get_csv_row(row, fields)) - response = self.request.response - response.text = data.getvalue().decode('utf_8') - data.close() - response.content_length = len(response.text) - response.content_type = b'text/csv' - response.content_disposition = b'attachment; filename=batch.csv' - return response - - def get_csv_fields(self): - """ - Return the list of 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): - if prop.key != 'removed' and not prop.key.endswith('uuid'): - fields.append(prop.key) - return fields - - def get_csv_row(self, row, fields): - """ - Return a dict for use when writing the row's data to CSV download. - """ - csvrow = {} - for field in fields: - value = getattr(row, field) - csvrow[field] = '' if value is None else unicode(value) - return csvrow - - @classmethod - def defaults(cls, config): - cls._batch_defaults(config) - cls._defaults(config) - - @classmethod - def _batch_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() - - # refresh rows 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='{}.create'.format(permission_prefix)) - - # bulk delete rows - config.add_route('{}.rows.bulk_delete'.format(route_prefix), '{}/{{uuid}}/rows/delete'.format(url_prefix)) - config.add_view(cls, attr='bulk_delete_rows', route_name='{}.rows.bulk_delete'.format(route_prefix), - permission='{}.edit'.format(permission_prefix)) - - # execute batch - config.add_route('{}.execute'.format(route_prefix), '{}/{{uuid}}/execute'.format(url_prefix)) - config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix), - permission='{}.execute'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix), - "Execute {}".format(model_title)) - - # download rows as CSV - config.add_route('{}.csv'.format(route_prefix), '{}/{{uuid}}/csv'.format(url_prefix)) - config.add_view(cls, attr='csv', route_name='{}.csv'.format(route_prefix), - permission='{}.csv'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.csv'.format(permission_prefix), - "Download {} rows as CSV".format(model_title)) - - -class FileBatchMasterView(BatchMasterView): - """ - Base class for all file-based "batch master" views. - """ - - @property - def upload_dir(self): - """ - The path to the root upload folder, to be used as the ``storage_path`` - argument for the file field renderer. - """ - uploads = os.path.join( - self.rattail_config.require('rattail', 'batch.files'), - 'uploads') - uploads = self.rattail_config.get('tailbone', 'batch.uploads', - default=uploads) - if not os.path.exists(uploads): - os.makedirs(uploads) - return uploads - - def preconfigure_grid(self, g): - super(FileBatchMasterView, self).preconfigure_grid(g) - g.created.set(label="Uploaded") - g.created_by.set(label="Uploaded by") - - def _preconfigure_fieldset(self, fs): - super(FileBatchMasterView, self)._preconfigure_fieldset(fs) - fs.created.set(label="Uploaded") - fs.created_by.set(label="Uploaded by") - fs.filename.set(label="Data File", renderer=FileFieldRenderer.new(self)) - if self.editing: - fs.filename.set(readonly=True) - - def configure_fieldset(self, fs): - """ - Apply final configuration to the main batch fieldset. Custom batch - views are encouraged to override this method. - """ - if self.creating: - fs.configure( - include=[ - fs.filename, - ]) - - 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 save_create_form(self, form): - self.before_create(form) - - # Transfer form data to batch instance. - form.fieldset.sync() - batch = form.fieldset.model - - # Assign current user as creator. - with Session.no_autoflush: - batch.created_by = self.request.user or self.late_login_user() - - # TODO: Wouldn't this be handled sufficiently by `no_autoflush` ? - # Expunge batch from session to prevent it from being flushed - # during init. This is done as a convenience to views which - # provide an init method. Some batches may have required fields - # which aren't filled in yet, but the view may need to query the - # database to obtain the values. This will cause a session flush, - # and the missing fields will trigger data integrity errors. - Session.expunge(batch) - - self.batch_inited = self.init_batch(batch) - - if self.batch_inited: - Session.add(batch) - Session.flush() - - # Handler saves a copy of the file and updates the batch filename. - path = os.path.join(self.upload_dir, batch.filename) - self.handler.set_data_file(batch, path) - os.remove(path) - - else: # batch init failed - - # Here we assume that the :meth:`init_batch()` method responsible - # for indicating the failure will have set a flash message for the - # user with more info. - raise self.redirect(self.request.current_route_url()) - - def redirect_after_create(self, batch): - return self.redirect(self.get_action_url('refresh', batch)) - - def download(self): - """ - View for downloading the data file associated with a batch. - """ - batch = self.get_instance() - if not batch: - raise httpexceptions.HTTPNotFound() - path = self.handler.data_path(batch) - 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() - - # 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)) - - -class BaseGrid(SearchableAlchemyGridView): - """ - Base view for batch and batch row grid views. You should not derive from - this class, but :class:`BatchGrid` or :class:`BatchRowGrid` instead. - """ - - @property - def config_prefix(self): - """ - Config prefix for the grid view. This is used to keep track of current - filtering and sorting, within the user's session. Derived classes may - override this. - """ - return self.mapped_class.__name__.lower() - - @property - def permission_prefix(self): - """ - Permission prefix for the grid view. This is used to automatically - protect certain views common to all batches. Derived classes can - override this. - """ - return self.route_prefix - - def join_map_extras(self): - """ - Derived classes can override this. The value returned will be used to - supplement the default join map. - """ - return {} - - def filter_map_extras(self): - """ - Derived classes can override this. The value returned will be used to - supplement the default filter map. - """ - return {} - - def make_filter_map(self, **kwargs): - """ - Make a filter map by combining kwargs from the base class, with extras - supplied by a derived class. - """ - extras = self.filter_map_extras() - exact = extras.pop('exact', None) - if exact: - kwargs.setdefault('exact', []).extend(exact) - ilike = extras.pop('ilike', None) - if ilike: - kwargs.setdefault('ilike', []).extend(ilike) - kwargs.update(extras) - return super(BaseGrid, self).make_filter_map(**kwargs) - - def filter_config_extras(self): - """ - Derived classes can override this. The value returned will be used to - supplement the default filter config. - """ - return {} - - def sort_map_extras(self): - """ - Derived classes can override this. The value returned will be used to - supplement the default sort map. - """ - return {} - - def _configure_grid(self, grid): - """ - Internal method for configuring the grid. This is meant only for base - classes; derived classes should not need to override it. - """ - - def configure_grid(self, grid): - """ - Derived classes can override this. Customizes a grid which has already - been created with defaults by the base class. - """ - - -class BatchGrid(BaseGrid): - """ - Base grid view for batches, which can be filtered and sorted. - """ - - @property - def batch_class(self): - raise NotImplementedError - - @property - def mapped_class(self): - return self.batch_class - - @property - def batch_display(self): - """ - Singular display text for the batch type, e.g. "Vendor Invoice". - Override this as necessary. - """ - return self.batch_class.__name__ - - @property - def batch_display_plural(self): - """ - Plural display text for the batch type, e.g. "Vendor Invoices". - Override this as necessary. - """ - return "{0}s".format(self.batch_display) - - def join_map(self): - """ - Provides the default join map for batch grid views. Derived classes - should *not* override this, but :meth:`join_map_extras()` instead. - """ - map_ = { - 'created_by': - lambda q: q.join(model.User, model.User.uuid == self.batch_class.created_by_uuid), - 'executed_by': - lambda q: q.outerjoin(model.User, model.User.uuid == self.batch_class.executed_by_uuid), - } - map_.update(self.join_map_extras()) - return map_ - - def filter_map(self): - """ - Provides the default filter map for batch grid views. Derived classes - should *not* override this, but :meth:`filter_map_extras()` instead. - """ - - def executed_is(q, v): - if v == 'True': - return q.filter(self.batch_class.executed != None) - else: - return q.filter(self.batch_class.executed == None) - - def executed_nt(q, v): - if v == 'True': - return q.filter(self.batch_class.executed == None) - else: - return q.filter(self.batch_class.executed != None) - - return self.make_filter_map( - executed={'is': executed_is, 'nt': executed_nt}) - - def filter_config(self): - """ - Provides the default filter config for batch grid views. Derived - classes should *not* override this, but :meth:`filter_config_extras()` - instead. - """ - defaults = self.filter_config_extras() - config = self.make_filter_config( - filter_factory_executed=BooleanSearchFilter, - filter_type_executed='is', - executed=False, - include_filter_executed=True) - defaults.update(config) - return defaults - - def sort_map(self): - """ - Provides the default sort map for batch grid views. Derived classes - should *not* override this, but :meth:`sort_map_extras()` instead. - """ - map_ = self.make_sort_map( - created_by=self.sorter(model.User.username), - executed_by=self.sorter(model.User.username)) - map_.update(self.sort_map_extras()) - return map_ - - def sort_config(self): - """ - Provides the default sort config for batch grid views. Derived classes - may override this. - """ - return self.make_sort_config(sort='created', dir='desc') - - def grid(self): - """ - Creates the grid for the view. Derived classes should *not* override - this, but :meth:`configure_grid()` instead. - """ - g = self.make_grid() - g.created_by.set(renderer=forms.renderers.UserFieldRenderer) - g.cognized_by.set(renderer=forms.renderers.UserFieldRenderer) - g.executed_by.set(renderer=forms.renderers.UserFieldRenderer) - self._configure_grid(g) - self.configure_grid(g) - if self.request.has_perm('{0}.view'.format(self.permission_prefix)): - g.viewable = True - g.view_route_name = '{0}.view'.format(self.route_prefix) - if self.request.has_perm('{0}.edit'.format(self.permission_prefix)): - g.editable = True - g.edit_route_name = '{0}.edit'.format(self.route_prefix) - if self.request.has_perm('{0}.delete'.format(self.permission_prefix)): - g.deletable = True - g.delete_route_name = '{0}.delete'.format(self.route_prefix) - return g - - def _configure_grid(self, grid): - grid.created_by.set(label="Created by") - grid.executed_by.set(label="Executed by") - - def configure_grid(self, grid): - """ - Derived classes can override this. Customizes a grid which has already - been created with defaults by the base class. - """ - g = grid - g.configure( - include=[ - g.created, - g.created_by, - g.executed, - g.executed_by, - ], - readonly=True) - - def render_kwargs(self): - """ - Add some things to the template context: batch type display name, route - and permission prefixes. - """ - return { - 'batch_display': self.batch_display, - 'batch_display_plural': self.batch_display_plural, - 'route_prefix': self.route_prefix, - 'permission_prefix': self.permission_prefix, - } - - -class FileBatchGrid(BatchGrid): - """ - Base grid view for batches, which involve primarily a file upload. - """ - - def _configure_grid(self, g): - super(FileBatchGrid, self)._configure_grid(g) - g.created.set(label="Uploaded") - g.created_by.set(label="Uploaded by") - - def configure_grid(self, grid): - """ - Derived classes can override this. Customizes a grid which has already - been created with defaults by the base class. - """ - g = grid - g.configure( - include=[ - g.created, - g.created_by, - g.filename, - g.executed, - g.executed_by, - ], - readonly=True) - - -class BaseCrud(CrudView): - """ - Base CRUD view for batches and batch rows. - """ - flash = {} - - @property - def home_route(self): - """ - The "home" route for the batch type, i.e. its grid view. - """ - return self.route_prefix - - @property - def permission_prefix(self): - """ - Permission prefix used to generically protect certain views common to - all batches. Derived classes can override this. - """ - return self.route_prefix - - def flash_create(self, model): - if 'create' in self.flash: - self.request.session.flash(self.flash['create']) - else: - super(BaseCrud, self).flash_create(model) - - def flash_update(self, model): - if 'update' in self.flash: - self.request.session.flash(self.flash['update']) - else: - super(BaseCrud, self).flash_update(model) - - def flash_delete(self, model): - if 'delete' in self.flash: - self.request.session.flash(self.flash['delete']) - else: - super(BaseCrud, self).flash_delete(model) - - -class BatchCrud(BaseCrud): - """ - Base CRUD view for batches. - """ - refreshable = False - flash = {} - - @property - def batch_class(self): - raise NotImplementedError - - @property - def batch_row_class(self): - raise NotImplementedError - - @property - def mapped_class(self): - return self.batch_class - - @classmethod - def get_route_prefix(cls): - return cls.route_prefix - - @property - def permission_prefix(self): - """ - Permission prefix for the grid view. This is used to automatically - protect certain views common to all batches. Derived classes can - and - typically should - override this. - """ - return self.route_prefix - - @property - def batch_display_plural(self): - """ - Plural display text for the batch type. - """ - return "{0}s".format(self.batch_display) - - def __init__(self, request): - self.request = request - self.handler = self.get_handler() - - def get_handler(self): - """ - Returns a `BatchHandler` instance for the view. Derived classes may - override this as needed. The default is to create an instance of - :attr:`batch_handler_class`. - """ - return self.batch_handler_class(self.request.rattail_config) - - def fieldset(self, model): - """ - Creates the fieldset for the view. Derived classes should *not* - override this, but :meth:`configure_fieldset()` instead. - """ - fs = self.make_fieldset(model) - 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.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer) - self.configure_fieldset(fs) - if self.creating: - del fs.created - del fs.created_by - if 'cognized' in fs.render_fields: - del fs.cognized - if 'cognized_by' in fs.render_fields: - del fs.cognized_by - if 'executed' in fs.render_fields: - del fs.executed - if 'executed_by' in fs.render_fields: - del fs.executed_by - else: - batch = fs.model - if not batch.executed: - if 'executed' in fs.render_fields: - del fs.executed - if 'executed_by' in fs.render_fields: - del fs.executed_by - return fs - - def configure_fieldset(self, fieldset): - """ - Derived classes can override this. Customizes a fieldset which has - already been created with defaults by the base class. - """ - fs = fieldset - fs.configure( - include=[ - fs.created, - fs.created_by, - # fs.cognized, - # fs.cognized_by, - fs.executed, - fs.executed_by, - ]) - - def init_batch(self, batch): - """ - Initialize a new batch. Derived classes can override this to - effectively provide default values for a batch, etc. This method is - invoked after a batch has been fully prepared for insertion to the - database, but before the push to the database occurs. - - Note that the return value of this function matters; if it is boolean - false then the batch will not be persisted at all, and the user will be - redirected to the "create batch" page. - """ - return True - - def save_form(self, form): - """ - Save the uploaded data file if necessary, etc. If batch initialization - fails, don't persist the batch at all; the user will be sent back to - the "create batch" page in that case. - """ - # Transfer form data to batch instance. - form.fieldset.sync() - batch = form.fieldset.model - - # For new batches, assign current user as creator, etc. - if self.creating: - with Session.no_autoflush: - batch.created_by = self.request.user or self.late_login_user() - - # Expunge batch from session to prevent it from being flushed - # during init. This is done as a convenience to views which - # provide an init method. Some batches may have required fields - # which aren't filled in yet, but the view may need to query the - # database to obtain the values. This will cause a session flush, - # and the missing fields will trigger data integrity errors. - Session.expunge(batch) - self.batch_inited = self.init_batch(batch) - if self.batch_inited: - Session.add(batch) - Session.flush() - - def update(self): - """ - Don't allow editing a batch which has already been executed. - """ - batch = self.get_model_from_request() - if not batch: - raise httpexceptions.HTTPNotFound() - if batch.executed: - raise httpexceptions.HTTPFound(location=self.view_url(batch.uuid)) - return self.crud(batch) - - def post_create_url(self, form): - """ - Redirect to view batch after creating a batch. - """ - batch = form.fieldset.model - Session.flush() - return self.view_url(batch.uuid) - - def post_update_url(self, form): - """ - Redirect back to edit batch page after editing a batch, unless the - refresh flag is set, in which case do that. - """ - if self.request.params.get('refresh') == 'true': - return self.refresh_url() - return self.request.current_route_url() - - def template_kwargs(self, form): - """ - Add some things to the template context: current batch model, batch - type display name, route and permission prefixes, batch row grid. - """ - batch = form.fieldset.model - batch.refreshable = self.refreshable - kwargs = { - 'batch': batch, - 'batch_display': self.batch_display, - 'batch_display_plural': self.batch_display_plural, - 'execute_title': self.handler.get_execute_title(batch), - 'route_prefix': self.route_prefix, - 'permission_prefix': self.permission_prefix, - } - if not self.creating: - kwargs['execute_enabled'] = self.executable(batch) - return kwargs - - def executable(self, batch): - return self.handler.executable(batch) - - def flash_create(self, batch): - if 'create' in self.flash: - self.request.session.flash(self.flash['create']) - else: - super(BatchCrud, self).flash_create(batch) - - def flash_delete(self, batch): - if 'delete' in self.flash: - self.request.session.flash(self.flash['delete']) - else: - super(BatchCrud, self).flash_delete(batch) - - def current_batch(self): - """ - Return the current batch, based on the UUID within the URL. - """ - return Session.query(self.mapped_class).get(self.request.matchdict['uuid']) - - def refresh(self): - """ - View which will attempt to refresh all data for the batch. What - exactly this means will depend on the type of batch etc. - """ - batch = self.current_batch() - - # If handler doesn't declare the need for progress indicator, things - # are nice and simple. - if not self.handler.show_progress: - self.refresh_data(Session, batch) - self.request.session.flash("Batch data has been refreshed.") - raise httpexceptions.HTTPFound(location=self.view_url(batch.uuid)) - - # Showing progress requires a separate thread; start that first. - key = '{0}.refresh'.format(self.batch_class.__tablename__) - progress = SessionProgress(self.request, key) - thread = Thread(target=self.refresh_thread, args=(batch.uuid, progress)) - thread.start() - - # Send user to progress page. - kwargs = { - 'key': key, - 'cancel_url': self.view_url(batch.uuid), - 'cancel_msg': "Batch refresh was canceled.", - } - return self.render_progress(kwargs) - - def refresh_data(self, session, batch, cognizer=None, progress=None): - """ - Instruct the batch handler to refresh all data for the batch. - """ - self.handler.refresh_data(session, batch, progress=progress) - batch.cognized = datetime.datetime.utcnow() - if cognizer is not None: - batch.cognized_by = cognizer - else: - batch.cognized_by = session.merge(self.request.user) - - def refresh_thread(self, batch_uuid, progress=None, cognizer_uuid=None, success_url=None): - """ - 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.batch_class).get(batch_uuid) - cognizer = session.query(model.User).get(cognizer_uuid) if cognizer_uuid else None - try: - self.refresh_data(session, batch, cognizer=cognizer, progress=progress) - except Exception as error: - session.rollback() - log.warning("refreshing data for batch failed: {0}".format(batch), exc_info=True) - session.close() - if progress: - progress.session.load() - progress.session['error'] = True - progress.session['error_msg'] = "Data refresh failed: {0}".format(error) - progress.session.save() - return - - session.commit() - session.refresh(batch) - session.close() - - # Finalize progress indicator. - if progress: - progress.session.load() - progress.session['complete'] = True - progress.session['success_url'] = success_url or self.view_url(batch.uuid) - progress.session.save() - - def view_url(self, uuid=None): - """ - Returns the URL for viewing a batch; defaults to current batch. - """ - if uuid is None: - uuid = self.request.matchdict['uuid'] - return self.request.route_url('{0}.view'.format(self.route_prefix), uuid=uuid) - - def refresh_url(self, uuid=None): - """ - Returns the URL for refreshing a batch; defaults to current batch. - """ - if uuid is None: - uuid = self.request.matchdict['uuid'] - return self.request.route_url('{0}.refresh'.format(self.route_prefix), uuid=uuid) - - def execute(self): - """ - Execute a batch. Starts a separate thread for the execution, and - displays a progress indicator page. - """ - batch = self.current_batch() - - key = '{0}.execute'.format(self.batch_class.__tablename__) - progress = SessionProgress(self.request, key) - thread = Thread(target=self.execute_thread, args=(batch.uuid, progress)) - thread.start() - - kwargs = { - 'key': key, - 'cancel_url': self.view_url(batch.uuid), - 'cancel_msg': "Batch execution was canceled.", - } - return self.render_progress(kwargs) - - def execute_thread(self, batch_uuid, progress=None): - """ - 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.batch_class).get(batch_uuid) - try: - result = self.handler.execute(batch, progress=progress) - - # If anything goes wrong, rollback and log the error etc. - except Exception as error: - session.rollback() - log.exception("execution failed for batch: {0}".format(batch)) - session.close() - if progress: - progress.session.load() - progress.session['error'] = True - progress.session['error_msg'] = "Batch execution failed: {0}".format(error) - progress.session.save() - - # If no error, check result flag (false means user canceled). - else: - if result: - batch.executed = datetime.datetime.utcnow() - batch.executed_by = session.merge(self.request.user) - session.commit() - else: - session.rollback() - session.refresh(batch) - session.close() - - if progress: - progress.session.load() - progress.session['complete'] = True - progress.session['success_url'] = self.view_url(batch.uuid) - progress.session.save() - - def csv(self): - """ - Download batch data as CSV. - """ - batch = self.current_batch() - fields = self.get_csv_fields() - data = StringIO() - writer = UnicodeDictWriter(data, fields) - writer.writeheader() - for row in batch.data_rows: - if not row.removed: - writer.writerow(self.get_csv_row(row, fields)) - response = self.request.response - response.text = data.getvalue().decode('utf_8') - data.close() - response.content_length = len(response.text) - response.content_type = b'text/csv' - response.content_disposition = b'attachment; filename=batch.csv' - return response - - def get_csv_fields(self): - """ - Return the list of fields to be written to CSV download. - """ - fields = [] - mapper = orm.class_mapper(self.batch_row_class) - for prop in mapper.iterate_properties: - if isinstance(prop, orm.ColumnProperty): - if prop.key != 'removed' and not prop.key.endswith('uuid'): - fields.append(prop.key) - return fields - - def get_csv_row(self, row, fields): - """ - Return a dict for use when writing the row's data to CSV download. - """ - csvrow = {} - for field in fields: - value = getattr(row, field) - csvrow[field] = '' if value is None else unicode(value) - return csvrow - - -class FileBatchCrud(BatchCrud): - """ - Base CRUD view for batches which involve a file upload as the first step. - """ - refreshable = True - - def post_create_url(self, form): - """ - Redirect to refresh batch after creating a batch. - """ - batch = form.fieldset.model - Session.flush() - return self.refresh_url(batch.uuid) - - @property - def upload_dir(self): - """ - The path to the root upload folder, to be used as the ``storage_path`` - argument for the file field renderer. - """ - uploads = os.path.join( - self.request.rattail_config.require('rattail', 'batch.files'), - 'uploads') - uploads = self.request.rattail_config.get( - 'tailbone', 'batch.uploads', default=uploads) - if not os.path.exists(uploads): - os.makedirs(uploads) - return uploads - - def fieldset(self, model): - """ - Creates the fieldset for the view. Derived classes should *not* - override this, but :meth:`configure_fieldset()` instead. - """ - fs = self.make_fieldset(model) - fs.created.set(label="Uploaded", readonly=True) - fs.created_by.set(label="Uploaded by", renderer=forms.renderers.UserFieldRenderer, readonly=True) - fs.cognized_by.set(label="Cognized by", renderer=forms.renderers.UserFieldRenderer) - fs.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer) - fs.filename.set(renderer=FileFieldRenderer.new(self), label="Data File") - self.configure_fieldset(fs) - if self.creating: - if 'created' in fs.render_fields: - del fs.created - if 'created_by' in fs.render_fields: - del fs.created_by - if 'cognized' in fs.render_fields: - del fs.cognized - if 'cognized_by' in fs.render_fields: - del fs.cognized_by - if 'executed' in fs.render_fields: - del fs.executed - if 'executed_by' in fs.render_fields: - del fs.executed_by - if 'data_rows' in fs.render_fields: - del fs.data_rows - else: - if self.updating and 'filename' in fs.render_fields: - fs.filename.set(readonly=True) - batch = fs.model - if not batch.executed: - if 'executed' in fs.render_fields: - del fs.executed - if 'executed_by' in fs.render_fields: - del fs.executed_by - return fs - - def configure_fieldset(self, fieldset): - """ - Derived classes can override this. Customizes a fieldset which has - already been created with defaults by the base class. - """ - fs = fieldset - fs.configure( - include=[ - fs.created, - fs.created_by, - fs.filename, - # fs.cognized, - # fs.cognized_by, - fs.executed, - fs.executed_by, - ]) - - def save_form(self, form): - """ - Save the uploaded data file if necessary, etc. If batch initialization - fails, don't persist the batch at all; the user will be sent back to - the "create batch" page in that case. - """ - # Transfer form data to batch instance. - form.fieldset.sync() - batch = form.fieldset.model - - # For new batches, assign current user as creator, save file etc. - if self.creating: - with Session.no_autoflush: - batch.created_by = self.request.user or self.late_login_user() - - # Expunge batch from session to prevent it from being flushed - # during init. This is done as a convenience to views which - # provide an init method. Some batches may have required fields - # which aren't filled in yet, but the view may need to query the - # database to obtain the values. This will cause a session flush, - # and the missing fields will trigger data integrity errors. - Session.expunge(batch) - self.batch_inited = self.init_batch(batch) - if self.batch_inited: - Session.add(batch) - Session.flush() - - # Handler saves a copy of the file and updates the batch filename. - path = os.path.join(self.upload_dir, batch.filename) - self.handler.set_data_file(batch, path) - os.remove(path) - - def post_save(self, form): - """ - This checks for failed batch initialization when creating a new batch. - If a failure is detected, the user is redirected to the page for - creating new batches. The assumption here is that the - :meth:`init_batch()` method responsible for indicating the failure will - have set a flash message for the user with more info. - """ - if self.creating and not self.batch_inited: - raise httpexceptions.HTTPFound(location=self.request.route_url( - '{}.create'.format(self.route_prefix))) - - def pre_delete(self, batch): - """ - Delete all data (files etc.) for the batch. - """ - batch.delete_data(self.request.rattail_config) - del batch.data_rows[:] - - def download(self): - """ - View for downloading the data file associated with a batch. - """ - batch = self.current_batch() - if not batch: - raise httpexceptions.HTTPNotFound() - path = self.handler.data_path(batch) - 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="{0}"'.format(filename) - return response - - -class StatusRenderer(forms.renderers.EnumFieldRenderer): - """ - Custom renderer for ``status_code`` fields. Adds ``status_text`` value as - title attribute if it exists. - """ - - 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 - - -class BatchRowGrid(BaseGrid): - """ - Base grid view for batch rows, which can be filtered and sorted. Also it - can delete all rows matching the current list view query. - """ - - @property - def row_class(self): - raise NotImplementedError - - @property - def mapped_class(self): - return self.row_class - - @property - def config_prefix(self): - """ - Config prefix for the grid view. This is used to keep track of current - filtering and sorting, within the user's session. Derived classes may - override this. - """ - return '{0}.{1}'.format(self.mapped_class.__name__.lower(), - self.request.matchdict['uuid']) - - @property - def batch_class(self): - """ - Model class of the batch to which the rows belong. - """ - return self.row_class.__batch_class__ - - @property - def batch_display(self): - """ - Singular display text for the batch type, e.g. "Vendor Invoice". - Override this as necessary. - """ - return self.batch_class.__name__ - - def current_batch(self): - """ - Return the current batch, based on the UUID within the URL. - """ - return Session.query(self.batch_class).get(self.request.matchdict['uuid']) - - def modify_query(self, q): - q = super(BatchRowGrid, self).modify_query(q) - q = q.filter(self.row_class.batch == self.current_batch()) - q = q.filter(self.row_class.removed == False) - return q - - def join_map(self): - """ - Provides the default join map for batch row grid views. Derived - classes should *not* override this, but :meth:`join_map_extras()` - instead. - """ - return self.join_map_extras() - - def filter_map(self): - """ - Provides the default filter map for batch row grid views. Derived - classes should *not* override this, but :meth:`filter_map_extras()` - instead. - """ - return self.make_filter_map(exact=['status_code']) - - def filter_config(self): - """ - Provides the default filter config for batch grid views. Derived - classes should *not* override this, but :meth:`filter_config_extras()` - instead. - """ - kwargs = {'filter_label_status_code': "Status", - 'filter_factory_status_code': EnumSearchFilter(self.row_class.STATUS)} - kwargs.update(self.filter_config_extras()) - return self.make_filter_config(**kwargs) - - def sort_map(self): - """ - Provides the default sort map for batch grid views. Derived classes - should *not* override this, but :meth:`sort_map_extras()` instead. - """ - map_ = self.make_sort_map() - map_.update(self.sort_map_extras()) - return map_ - - def sort_config(self): - """ - Provides the default sort config for batch grid views. Derived classes - may override this. - """ - return self.make_sort_config(sort='sequence', dir='asc') - - def grid(self): - """ - Creates the grid for the view. Derived classes should *not* override - this, but :meth:`configure_grid()` instead. - """ - g = self.make_grid() - g.extra_row_class = self.tr_class - g.sequence.set(label="Seq.") - g.status_code.set(label="Status", renderer=StatusRenderer(self.row_class.STATUS)) - self._configure_grid(g) - self.configure_grid(g) - - batch = self.current_batch() - g.viewable = True - g.view_route_name = '{0}.row.view'.format(self.route_prefix) - # TODO: Fix this check for edit mode. - edit_mode = self.request.referrer.endswith('/edit') - if edit_mode and not batch.executed and self.request.has_perm('{0}.edit'.format(self.permission_prefix)): - # g.editable = True - # g.edit_route_name = '{0}.rows.edit'.format(self.route_prefix) - g.deletable = True - g.delete_route_name = '{0}.rows.delete'.format(self.route_prefix) - return g - - def tr_class(self, row, i): - pass - - def render_kwargs(self): - """ - Add the current batch and route prefix to the template context. - """ - return {'batch': self.current_batch(), - 'route_prefix': self.route_prefix} - - def bulk_delete(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. - """ - self.query().update({'removed': True}, synchronize_session=False) - return httpexceptions.HTTPFound(location=self.request.route_url('{}.view'.format(self.route_prefix), - uuid=self.request.matchdict['uuid'])) - - -class ProductBatchRowGrid(BatchRowGrid): - """ - Base grid view for batch rows which deal directly with products. - """ - - def filter_map(self): - """ - Provides the default filter map for batch row grid views. Derived - classes should *not* override this, but :meth:`filter_map_extras()` - instead. - """ - return self.make_filter_map(exact=['status_code'], - ilike=['brand_name', 'description', 'size'], - upc=self.filter_gpc(self.row_class.upc)) - - def filter_config(self): - """ - Provides the default filter config for batch grid views. Derived - classes should *not* override this, but :meth:`filter_config_extras()` - instead. - """ - kwargs = {'filter_label_status_code': "Status", - 'filter_factory_status_code': EnumSearchFilter(self.row_class.STATUS), - 'filter_label_upc': "UPC", - 'filter_label_brand_name': "Brand"} - kwargs.update(self.filter_config_extras()) - return self.make_filter_config(**kwargs) - - -class BatchRowCrud(BaseCrud): - """ - Base CRUD view for batch rows. - """ - - @property - def row_class(self): - raise NotImplementedError - - @property - def mapped_class(self): - return self.row_class - - @property - def batch_class(self): - """ - Model class of the batch to which the rows belong. - """ - return self.row_class.__batch_class__ - - @property - def batch_display(self): - """ - Singular display text for the batch type, e.g. "Vendor Invoice". - Override this as necessary. - """ - return self.batch_class.__name__ - - def fieldset(self, row): - """ - Creates the fieldset for the view. Derived classes should *not* - override this, but :meth:`configure_fieldset()` instead. - """ - fs = self.make_fieldset(row) - self.configure_fieldset(fs) - return fs - - def configure_fieldset(self, fieldset): - """ - Derived classes can override this. Customizes a fieldset which has - already been created with defaults by the base class. - """ - fieldset.configure() - - def template_kwargs(self, form): - """ - Add batch row instance etc. to template context. - """ - row = form.fieldset.model - return { - 'row': row, - 'batch_display': self.batch_display, - 'route_prefix': self.route_prefix, - 'permission_prefix': self.permission_prefix, - } - - def delete(self): - """ - "Delete" a row from the batch. This sets the ``removed`` flag on the - row but does not truly delete it. - """ - row = self.get_model_from_request() - if not row: - raise httpexceptions.HTTPNotFound() - row.removed = True - return httpexceptions.HTTPFound(location=self.request.route_url('{}.view'.format(self.route_prefix), - uuid=row.batch_uuid)) - - -def defaults(config, batch_grid, batch_crud, row_grid, row_crud, url_prefix, - route_prefix=None, permission_prefix=None, template_prefix=None): - """ - Apply default configuration to the Pyramid configurator object, for the - given batch grid and CRUD views. - """ - assert batch_grid - assert batch_crud - assert url_prefix - if route_prefix is None: - route_prefix = batch_grid.route_prefix - if permission_prefix is None: - permission_prefix = route_prefix - if template_prefix is None: - template_prefix = url_prefix - template_prefix.rstrip('/') - - # Batches grid - config.add_route(route_prefix, url_prefix) - config.add_view(batch_grid, route_name=route_prefix, - renderer='{0}/index.mako'.format(template_prefix), - permission='{0}.view'.format(permission_prefix)) - - # Create batch - config.add_route('{0}.create'.format(route_prefix), '{0}new'.format(url_prefix)) - config.add_view(batch_crud, attr='create', route_name='{0}.create'.format(route_prefix), - renderer='{0}/create.mako'.format(template_prefix), - permission='{0}.create'.format(permission_prefix)) - - # View batch - config.add_route('{0}.view'.format(route_prefix), '{0}{{uuid}}'.format(url_prefix)) - config.add_view(batch_crud, attr='read', route_name='{0}.view'.format(route_prefix), - renderer='{0}/view.mako'.format(template_prefix), - permission='{0}.view'.format(permission_prefix)) - - # Edit batch - config.add_route('{0}.edit'.format(route_prefix), '{0}{{uuid}}/edit'.format(url_prefix)) - config.add_view(batch_crud, attr='update', route_name='{0}.edit'.format(route_prefix), - renderer='{0}/edit.mako'.format(template_prefix), - permission='{0}.edit'.format(permission_prefix)) - - # Refresh batch row data - config.add_route('{0}.refresh'.format(route_prefix), '{0}{{uuid}}/refresh'.format(url_prefix)) - config.add_view(batch_crud, attr='refresh', route_name='{0}.refresh'.format(route_prefix), - permission='{0}.create'.format(permission_prefix)) - - # Execute batch - config.add_route('{0}.execute'.format(route_prefix), '{0}{{uuid}}/execute'.format(url_prefix)) - config.add_view(batch_crud, attr='execute', route_name='{0}.execute'.format(route_prefix), - permission='{0}.execute'.format(permission_prefix)) - - # Download batch row data as CSV - config.add_route('{0}.csv'.format(route_prefix), '{0}{{uuid}}/csv'.format(url_prefix)) - config.add_view(batch_crud, attr='csv', route_name='{0}.csv'.format(route_prefix), - permission='{0}.csv'.format(permission_prefix)) - - # Download batch data file - if hasattr(batch_crud, 'download'): - config.add_route('{0}.download'.format(route_prefix), '{0}{{uuid}}/download'.format(url_prefix)) - config.add_view(batch_crud, attr='download', route_name='{0}.download'.format(route_prefix), - permission='{0}.download'.format(permission_prefix)) - - # Delete batch - config.add_route('{0}.delete'.format(route_prefix), '{0}{{uuid}}/delete'.format(url_prefix)) - config.add_view(batch_crud, attr='delete', route_name='{0}.delete'.format(route_prefix), - permission='{0}.delete'.format(permission_prefix)) - - # Batch rows grid - config.add_route('{0}.rows'.format(route_prefix), '{0}{{uuid}}/rows/'.format(url_prefix)) - config.add_view(row_grid, route_name='{0}.rows'.format(route_prefix), - renderer='/batch/rows.mako', - permission='{0}.view'.format(permission_prefix)) - - # view batch row - config.add_route('{0}.row.view'.format(route_prefix), '{0}row/{{uuid}}'.format(url_prefix)) - config.add_view(row_crud, attr='read', route_name='{0}.row.view'.format(route_prefix), - renderer='{0}/row.view.mako'.format(template_prefix), - permission='{0}.view'.format(permission_prefix)) - - # Bulk delete batch rows - config.add_route('{0}.rows.bulk_delete'.format(route_prefix), '{0}{{uuid}}/rows/delete'.format(url_prefix)) - config.add_view(row_grid, attr='bulk_delete', route_name='{0}.rows.bulk_delete'.format(route_prefix), - permission='{0}.edit'.format(permission_prefix)) - - # Delete batch row - config.add_route('{0}.rows.delete'.format(route_prefix), '{0}delete-row/{{uuid}}'.format(url_prefix)) - config.add_view(row_crud, attr='delete', route_name='{0}.rows.delete'.format(route_prefix), - permission='{0}.edit'.format(permission_prefix)) diff --git a/tailbone/views/batch/__init__.py b/tailbone/views/batch/__init__.py new file mode 100644 index 00000000..e4b61802 --- /dev/null +++ b/tailbone/views/batch/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 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 batches +""" + +from __future__ import unicode_literals, absolute_import + +from .core import BatchMasterView, FileBatchMasterView diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py new file mode 100644 index 00000000..c162b579 --- /dev/null +++ b/tailbone/views/batch/core.py @@ -0,0 +1,1590 @@ +# -*- 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 views for maintaining "new-style" batches. +""" + +import os +import sys +import json +import datetime +import logging +import socket +import subprocess +import tempfile +import warnings + +import json +import markdown +import sqlalchemy as sa +from sqlalchemy import orm + +from rattail.threads import Thread +from rattail.util import simple_error + +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 + + +log = logging.getLogger(__name__) + + +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 + cloneable = False + executable = 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().__init__(request) + self.batch_handler = self.get_handler() + # TODO: deprecate / remove this (?) + self.handler = self.batch_handler + + @classmethod + def get_handler_factory(cls, rattail_config): + """ + 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] + 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. + """ + # 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']: + 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): + 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): + if batch.description: + return "{} {}".format(batch.id_str, batch.description) + return batch.id_str + + def configure_form(self, f): + super().configure_form(f) + + # id + f.set_readonly('id') + f.set_renderer('id', self.render_id_str) + f.set_label('id', "Batch ID") + + # params + if self.creating: + f.remove('params') + else: + f.set_readonly('params') + f.set_renderer('params', self.render_params) + + # created + f.set_readonly('created') + f.set_readonly('created_by') + 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: + f.remove_field('status_code') + else: + 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: + f.remove_fields('executed', + 'executed_by') + + 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) + + session = self.Session() + with session.no_autoflush: + + # transfer form data to batch instance + batch = self.objectify(form, self.form_deserialized) + + # current user is batch creator + batch.created_by = self.request.user or self.late_login_user() + + # obtain kwargs for making batch via handler, below + kwargs = self.get_batch_kwargs(batch) + + # 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: 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) + + self.Session.flush() + self.process_uploads(batch, form, uploads) + return batch + + 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. + """ + kwargs = {} + if batch.created_by: + kwargs['created_by'] = batch.created_by + elif batch.created_by_uuid: + kwargs['created_by_uuid'] = batch.created_by_uuid + kwargs['description'] = batch.description + kwargs['notes'] = batch.notes + if hasattr(batch, 'filename'): + kwargs['filename'] = batch.filename + kwargs['complete'] = batch.complete + return kwargs + + # TODO: deprecate / remove this (is it used at all now?) + def init_batch(self, batch): + """ + Initialize a new batch. Derived classes can override this to + effectively provide default values for a batch, etc. This method is + invoked after a batch has been fully prepared for insertion to the + database, but before the push to the database occurs. + + Note that the return value of this function matters; if it is boolean + false then the batch will not be persisted at all, and the user will be + redirected to the "create batch" page. + """ + return True + + def redirect_after_create(self, batch, **kwargs): + if self.handler.should_populate(batch): + return self.redirect(self.get_action_url('prefill', batch)) + elif self.refresh_after_create: + return self.redirect(self.get_action_url('refresh', batch)) + else: + return self.redirect(self.get_action_url('view', batch)) + + def template_kwargs_edit(self, **kwargs): + batch = kwargs['instance'] + kwargs['batch'] = batch + return kwargs + + 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(): + if form.validated['complete']: + self.mark_batch_complete(batch) + else: + self.mark_batch_incomplete(batch) + return self.redirect(self.get_action_url('view', batch)) + + def mark_batch_complete(self, batch): + self.handler.mark_complete(batch) + + 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 + or marked complete. + """ + 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): + """ + Only allow creating a new row if the batch hasn't yet been executed. + """ + 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)) + return super().create_row() + + def save_create_row_form(self, form): + batch = self.get_instance() + 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 and not batch.complete: + permission_prefix = self.get_permission_prefix() + if self.request.has_perm('{}.create_row'.format(permission_prefix)): + 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): + 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 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)) + return self.redirect(self.get_action_url('view', batch)) + + def delete_instance(self, batch): + """ + Delete all data (files etc.) for the batch. + """ + app = self.get_rattail_app() + session = app.get_session(batch) + self.batch_handler.do_delete(batch) + session.flush() + + def delete_instance_with_progress(self, batch): + """ + Delete all data (files etc.) for the batch. + """ + return self.handler_action(batch, 'delete') + + def delete_thread(self, key, user_uuid, progress, **kwargs): + """ + Thread target for deleting a batch with progress indicator. + """ + app = self.get_rattail_app() + model = self.model + # nb. must make new session, separate from main thread + session = app.make_session() + batch = self.get_instance_for_key(key, session) + batch_str = str(batch) + + try: + # try to delete batch + self.handler.do_delete(batch, progress=progress, **kwargs) + + except Exception as error: + # error; log that and rollback + log.exception("delete failed for batch: %s", batch) + session.rollback() + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Batch deletion failed: {}".format( + simple_error(error)) + progress.session.save() + + else: + # no error; finish up + session.commit() + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_index_url() + progress.session['success_msg'] = "Batch has been deleted: {}".format( + batch_str) + progress.session.save() + + def get_fallback_templates(self, template, **kwargs): + return [ + '/batch/{}.mako'.format(template), + '/master/{}.mako'.format(template), + ] + + def editable_instance(self, batch): + return not bool(batch.executed) + + def after_edit_row(self, row): + self.handler.refresh_row(row) + + def instance_executable(self, batch=None): + return self.handler.executable(batch) + + def batch_refreshable(self, batch): + """ + Return a boolean indicating whether the given batch should allow a + refresh operation. + """ + # TODO: deprecate/remove this? + if not self.refreshable: + return False + + # (this is how it should be done i think..) + if callable(self.handler.refreshable): + return self.handler.refreshable(batch) + + # TODO: deprecate/remove this + return self.handler.refreshable and not batch.executed + + def has_execution_options(self, batch=None): + return bool(self.execution_options_schema) + + # TODO + execution_options_schema = 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. + """ + defaults = {} + 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 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): + """ + View which will attempt to prefill all data for the batch. What + exactly this means will depend on the type of batch etc. + """ + batch = self.get_instance() + return self.handler_action(batch, 'populate') + + def populate_thread(self, batch_uuid, user_uuid, progress, **kwargs): + """ + 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 = app.make_session() + batch = session.get(self.model_class, batch_uuid) + user = session.get(model.User, user_uuid) + try: + self.handler.do_populate(batch, user, progress=progress) + session.flush() + except Exception as error: + session.rollback() + log.warning("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'] = simple_error(error) + progress.session.save() + return + + session.commit() + session.refresh(batch) + session.close() + + # finalize progress + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_action_url('view', batch) + progress.session.save() + + def refresh(self): + """ + View which will attempt to refresh all data for the batch. What + exactly this means will depend on the type of batch etc. + """ + batch = self.get_instance() + return self.handler_action(batch, 'refresh') + + def refresh_data(self, session, batch, user, progress=None): + """ + Instruct the batch handler to refresh all data for the batch. + """ + # TODO: deprecate/remove this + if hasattr(self.handler, 'refresh_data'): + self.handler.refresh_data(session, batch, progress=progress) + batch.cognized = datetime.datetime.utcnow() + batch.cognized_by = cognizer or session.merge(self.request.user) + + else: # the future + user = user or session.merge(self.request.user) + self.handler.do_refresh(batch, user, progress=progress) + + 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. + app = self.get_rattail_app() + model = self.model + session = app.make_session() + batch = session.get(self.model_class, batch_uuid) + cognizer = session.get(model.User, user_uuid) if user_uuid else None + try: + self.refresh_data(session, batch, cognizer, progress=progress) + session.flush() + except Exception as error: + session.rollback() + log.warning("refreshing data for batch failed: {}".format(batch), exc_info=True) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Data refresh failed: {}".format( + simple_error(error)) + progress.session.save() + return + + session.commit() + session.refresh(batch) + session.close() + + # Finalize progress indicator. + if progress: + progress.session.load() + progress.session['complete'] = True + 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 + ######################################## + + def get_row_instance_title(self, row): + return "Row {}".format(row.sequence) + + hide_row_status_codes = [] + + def get_row_data(self, batch): + """ + Generate the base data set for a rows grid. + """ + query = self.Session.query(self.model_row_class)\ + .filter(self.model_row_class.batch == batch)\ + .filter(self.model_row_class.removed == False) + if self.hide_row_status_codes: + query = query.filter(~self.model_row_class.status_code.in_(self.hide_row_status_codes)) + return query + + def row_editable(self, row): + """ + Batch rows are editable only until batch is complete or executed. + """ + if not (self.rows_editable or self.rows_editable_but_not_directly): + return False + + batch = self.get_parent(row) + if batch.complete or batch.executed: + return False + + return True + + def row_deletable(self, row): + """ + Batch rows are deletable only until batch is complete or executed. + """ + if not self.rows_deletable: + return False + + batch = self.get_parent(row) + + if batch.complete: + return False + + if batch.executed: + if not self.rows_deletable_if_executed: + return False + if not self.has_perm('delete_row_if_executed'): + return False + + return True + + def template_kwargs_view_row(self, **kwargs): + kwargs['batch_model_title'] = kwargs['parent_model_title'] + # 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_object(self, row): + """ + Perform the actual deletion of given row object. + """ + self.handler.do_remove_row(row) + + 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): + """ + Execute a batch. Starts a separate thread for the execution, and + displays a progress indicator page. + """ + batch = self.get_instance() + self.executing = True + form = self.make_execute_form(batch) + if form.validate(): + kwargs = dict(form.validated) + + # 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 + + return self.handler_action(batch, 'execute', **kwargs) + + 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(simple_error(error)) + + 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. + app = self.get_rattail_app() + model = self.model + session = app.make_session() + batch = self.get_instance_for_key(key, session) + user = session.get(model.User, user_uuid) + try: + result = self.handler.do_execute(batch, user=user, progress=progress, **kwargs) + + # If anything goes wrong, rollback and log the error etc. + except Exception as error: + session.rollback() + log.exception("execution failed for batch: {}".format(batch)) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = self.execute_error_message(error) + progress.session.save() + + # If no error, check result flag (false means user canceled). + else: + success_msg = None + if result: + session.commit() + success_msg = "{} has been executed: {}".format( + self.get_model_title(), batch.id_str) + else: + session.rollback() + + session.refresh(batch) + success_url = self.get_execute_success_url(batch, result, **kwargs) + 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 get_execute_success_url(self, batch, result, **kwargs): + return self.get_action_url('view', batch) + + def execute_results(self): + """ + Execute all batches which are returned from the current index query. + Starts a separate thread for the execution, and displays a progress + indicator page. + """ + form = self.make_execute_form() + if form.validate(): + kwargs = dict(form.validated) + + # 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 = self.make_progress(key) + kwargs['progress'] = progress + thread = Thread(target=self.execute_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", + }) + + 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. + """ + 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: + result = self.handler.execute_many(batches, user=user, progress=progress, **kwargs) + + # If anything goes wrong, rollback and log the error etc. + except Exception as error: + session.rollback() + log.exception("execution failed for batch results") + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = self.execute_error_message(error) + progress.session.save() + + # If no error, check result flag (false means user canceled). + else: + if result: + session.commit() + # TODO: this doesn't always work...? + self.request.session.flash("{} {} were executed".format( + len(batches), self.get_model_title_plural())) + success_url = self.get_execute_results_success_url(result, **kwargs) + else: + session.rollback() + success_url = self.get_index_url() + session.close() + + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = success_url + progress.session.save() + + def get_execute_results_success_url(self, result, **kwargs): + return self.get_index_url() + + def get_row_csv_fields(self): + fields = super().get_row_csv_fields() + fields = [field for field in fields + 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 + """ + batch = self.get_instance() + batch = self.handler.clone(batch, created_by=self.request.user) + return self.redirect(self.get_action_url('view', batch)) + + @classmethod + def defaults(cls, config): + cls._batch_defaults(config) + cls._defaults(config) + + @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() + + # TODO: currently must do this here (in addition to `_defaults()` or + # else the perm group label will not display correctly... + config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) + + # 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 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), + 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 + 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)) + + # delete row if executed + if cls.rows_deletable_if_executed: + config.add_tailbone_permission(permission_prefix, + f'{permission_prefix}.delete_row_if_executed', + "Delete rows after batch is executed") + + # toggle complete + config.add_route('{}.toggle_complete'.format(route_prefix), '{}/{{{}}}/toggle-complete'.format(url_prefix, model_key)) + config.add_view(cls, attr='toggle_complete', route_name='{}.toggle_complete'.format(route_prefix), + 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 + 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): + """ + The path to the root upload folder, to be used as the ``storage_path`` + argument for the file field renderer. + """ + uploads = os.path.join( + self.rattail_config.require('rattail', 'batch.files'), + 'uploads') + uploads = self.rattail_config.get('tailbone', 'batch.uploads', + default=uploads) + if not os.path.exists(uploads): + os.makedirs(uploads) + return uploads + + def configure_form(self, f): + super().configure_form(f) + batch = f.model_instance + + # filename + if self.creating: + # 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: + f.set_readonly('filename') + f.set_renderer('filename', self.render_downloadable_file) + + +class UploadWorksheet(colander.Schema): + + # this node is actually "replaced" when form is configured + worksheet_file = colander.SchemaNode(colander.String()) + + +class ToggleComplete(colander.MappingSchema): + + complete = colander.SchemaNode(colander.Boolean()) 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 new file mode 100644 index 00000000..5b5d013b --- /dev/null +++ b/tailbone/views/batch/pricing.py @@ -0,0 +1,387 @@ +# -*- 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 pricing batches +""" + +from rattail.db import model +from rattail.time import localtime + +from webhelpers2.html import tags, HTML + +from tailbone.views.batch import BatchMasterView + + +class PricingBatchView(BatchMasterView): + """ + Master view for pricing batches. + """ + model_class = model.PricingBatch + model_row_class = model.PricingBatchRow + default_handler_spec = 'rattail.batch.pricing:PricingBatchHandler' + model_title_plural = "Pricing Batches" + route_prefix = 'batch.pricing' + url_prefix = '/batches/pricing' + 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', + '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', + ] + + 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().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_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): + extra_class = None + + # 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' + + # 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): + 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/batches/__init__.py b/tailbone/views/batches/__init__.py deleted file mode 100644 index 0c6065cd..00000000 --- a/tailbone/views/batches/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ - -""" -Batch Views -""" - -from .params import * - - -def includeme(config): - config.include('tailbone.views.batches.core') - config.include('tailbone.views.batches.params') - config.include('tailbone.views.batches.rows') diff --git a/tailbone/views/batches/core.py b/tailbone/views/batches/core.py deleted file mode 100644 index 4518be57..00000000 --- a/tailbone/views/batches/core.py +++ /dev/null @@ -1,202 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Core Batch Views -""" - -from __future__ import unicode_literals, absolute_import - -from pyramid.httpexceptions import HTTPFound -from pyramid.renderers import render_to_response - -from webhelpers.html import tags - -from tailbone import forms -from ...grids.search import BooleanSearchFilter -from .. import SearchableAlchemyGridView, CrudView, View -from ...progress import SessionProgress - -from rattail import enum -from rattail import batches -from rattail.db.util import configure_session -from ...db import Session -from rattail.db.model import Batch -from rattail.threads import Thread - - -class BatchesGrid(SearchableAlchemyGridView): - - mapped_class = Batch - config_prefix = 'batches' - sort = 'id' - - def filter_map(self): - - def executed_is(q, v): - if v == 'True': - return q.filter(Batch.executed != None) - else: - return q.filter(Batch.executed == None) - - def executed_isnot(q, v): - if v == 'True': - return q.filter(Batch.executed == None) - else: - return q.filter(Batch.executed != None) - - return self.make_filter_map( - exact=['id'], - ilike=['source', 'destination', 'description'], - executed={ - 'is': executed_is, - 'nt': executed_isnot, - }) - - def filter_config(self): - return self.make_filter_config( - filter_label_id="ID", - filter_factory_executed=BooleanSearchFilter, - include_filter_executed=True, - filter_type_executed='is', - executed='False') - - def sort_map(self): - return self.make_sort_map('source', 'id', 'destination', 'description', 'executed') - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.source, - g.id.label("ID"), - g.destination, - g.description, - g.executed, - g.rowcount.label("Row Count"), - ], - readonly=True) - if self.request.has_perm('batches.read'): - def rows(row): - return tags.link_to("View Rows", self.request.route_url( - 'batch.rows', uuid=row.uuid)) - g.add_column('rows', "", rows) - g.viewable = True - g.view_route_name = 'batch.read' - if self.request.has_perm('batches.update'): - g.editable = True - g.edit_route_name = 'batch.update' - if self.request.has_perm('batches.delete'): - g.deletable = True - g.delete_route_name = 'batch.delete' - return g - - -class BatchCrud(CrudView): - - mapped_class = Batch - home_route = 'batches' - - def fieldset(self, model): - fs = self.make_fieldset(model) - fs.action_type.set(renderer=forms.renderers.EnumFieldRenderer(enum.BATCH_ACTION)) - fs.configure( - include=[ - fs.source, - fs.id.label("ID"), - fs.destination, - fs.action_type, - fs.description, - fs.rowcount.label("Row Count").readonly(), - fs.executed.readonly(), - ]) - return fs - - def post_delete(self, batch): - batch.drop_table() - - -class ExecuteBatch(View): - - def execute_batch(self, batch, progress): - from rattail.db import Session - session = Session() - configure_session(self.request.rattail_config, session) - batch = session.merge(batch) - - if not batch.execute(self.request.rattail_config, progress): - session.rollback() - session.close() - return - - session.commit() - session.refresh(batch) - session.close() - - progress.session.load() - progress.session['complete'] = True - progress.session['success_msg'] = "Batch \"%s\" has been executed." % batch.description - progress.session['success_url'] = self.request.route_url('batches') - progress.session.save() - - def __call__(self): - uuid = self.request.matchdict['uuid'] - batch = Session.query(Batch).get(uuid) if uuid else None - if not batch: - return HTTPFound(location=self.request.route_url('batches')) - - progress = SessionProgress(self.request, 'batch.execute') - thread = Thread(target=self.execute_batch, args=(batch, progress)) - thread.start() - kwargs = { - 'key': 'batch.execute', - 'cancel_url': self.request.route_url('batch.rows', uuid=batch.uuid), - 'cancel_msg': "Batch execution was canceled.", - } - return self.render_progress(kwargs) - - -def includeme(config): - - config.add_route('batches', '/batches') - config.add_view(BatchesGrid, route_name='batches', - renderer='/batches/index.mako', - permission='batches.list') - - config.add_route('batch.read', '/batches/{uuid}') - config.add_view(BatchCrud, attr='read', - route_name='batch.read', - renderer='/batches/read.mako', - permission='batches.read') - - config.add_route('batch.update', '/batches/{uuid}/edit') - config.add_view(BatchCrud, attr='update', route_name='batch.update', - renderer='/batches/crud.mako', - permission='batches.update') - - config.add_route('batch.delete', '/batches/{uuid}/delete') - config.add_view(BatchCrud, attr='delete', route_name='batch.delete', - permission='batches.delete') - - config.add_route('batch.execute', '/batches/{uuid}/execute') - config.add_view(ExecuteBatch, route_name='batch.execute', - permission='batches.execute') diff --git a/tailbone/views/batches/params/__init__.py b/tailbone/views/batches/params/__init__.py deleted file mode 100644 index 07a17939..00000000 --- a/tailbone/views/batches/params/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ - -""" -Batch Parameter Views -""" - -from ... import View - - -__all__ = ['BatchParamsView'] - - -class BatchParamsView(View): - - provider_name = None - - def render_kwargs(self): - return {} - - def __call__(self): - if self.request.POST: - if self.set_batch_params(): - return HTTPFound(location=self.request.get_referer()) - kwargs = self.render_kwargs() - kwargs['provider'] = self.provider_name - return kwargs - - -def includeme(config): - config.include('tailbone.views.batches.params.labels') diff --git a/tailbone/views/batches/params/labels.py b/tailbone/views/batches/params/labels.py deleted file mode 100644 index 49ef3bb1..00000000 --- a/tailbone/views/batches/params/labels.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ - -""" -Print Labels Batch -""" - -from __future__ import unicode_literals - -from rattail.db import model - -from tailbone.db import Session -from tailbone.views.batches.params import BatchParamsView - - -class PrintLabels(BatchParamsView): - - provider_name = 'print_labels' - - def render_kwargs(self): - q = Session.query(model.LabelProfile) - q = q.order_by(model.LabelProfile.ordinal) - profiles = [(x.code, x.description) for x in q] - return {'label_profiles': profiles} - - -def includeme(config): - - config.add_route('batch_params.print_labels', '/batches/params/print-labels') - config.add_view(PrintLabels, route_name='batch_params.print_labels', - renderer='/batches/params/print_labels.mako', - permission='batches.print_labels') diff --git a/tailbone/views/batches/rows.py b/tailbone/views/batches/rows.py deleted file mode 100644 index edf07c1b..00000000 --- a/tailbone/views/batches/rows.py +++ /dev/null @@ -1,252 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Batch Row Views -""" - -from __future__ import unicode_literals, absolute_import - -from pyramid.httpexceptions import HTTPFound - -from tailbone.views import SearchableAlchemyGridView, CrudView -from tailbone.forms import GPCFieldRenderer -from tailbone.grids.search import BooleanSearchFilter -from tailbone.db import Session - -from rattail.db import model -from rattail.db.model import Batch, LabelProfile -from rattail.gpc import GPC - - -def field_with_renderer(field, column): - - if column.sil_name == 'F01': # UPC - field = field.with_renderer(GPCFieldRenderer) - - elif column.sil_name == 'F95': # Shelf Tag Type - q = Session.query(LabelProfile) - q = q.order_by(LabelProfile.ordinal) - field = field.dropdown(options=[(x.description, x.code) for x in q]) - - return field - - -def BatchRowsGrid(request): - uuid = request.matchdict['uuid'] - batch = Session.query(Batch).get(uuid) if uuid else None - if not batch: - raise HTTPFound(location=request.route_url('batches')) - - class BatchRowsGrid(SearchableAlchemyGridView): - - mapped_class = batch.rowclass - config_prefix = 'batch.%s' % batch.uuid - sort = 'ordinal' - - def filter_map(self): - - # TODO: This code is copied from `tailbone.views.products`, which - # means we probably should refactor... - - def filter_F01_is(q, v): - if not v: - return q - try: - return q.filter(model.Product.upc.in_(( - GPC(v), GPC(v, calc_check_digit='upc')))) - except ValueError: - return q - - def filter_F01_not(q, v): - if not v: - return q - try: - return q.filter(~model.Product.upc.in_(( - GPC(v), GPC(v, calc_check_digit='upc')))) - except ValueError: - return q - - fmap = self.make_filter_map() - for column in batch.columns: - if column.sil_name == 'F01': - fmap[column.name] = {'is': filter_F01_is, - 'nt': filter_F01_not} - elif column.visible: - if column.data_type.startswith('CHAR'): - fmap[column.name] = self.filter_ilike( - getattr(batch.rowclass, column.name)) - else: - fmap[column.name] = self.filter_exact( - getattr(batch.rowclass, column.name)) - return fmap - - def filter_config(self): - config = self.make_filter_config() - for column in batch.columns: - if column.visible: - config['filter_label_%s' % column.name] = column.display_name - if column.data_type == 'FLAG(1)': - config['filter_factory_{0}'.format(column.name)] = BooleanSearchFilter - return config - - def grid(self): - g = self.make_grid() - - include = [g.ordinal.label("Row")] - for column in batch.columns: - if column.visible: - field = getattr(g, column.name) - field = field_with_renderer(field, column) - field = field.label(column.display_name) - include.append(field) - g.column_titles[field.key] = '%s - %s - %s' % ( - column.sil_name, column.description, column.data_type) - - g.configure(include=include, readonly=True) - - route_kwargs = lambda x: {'batch_uuid': x.batch.uuid, 'uuid': x.uuid} - - if self.request.has_perm('batch_rows.read'): - g.viewable = True - g.view_route_name = 'batch_row.read' - g.view_route_kwargs = route_kwargs - - if self.request.has_perm('batch_rows.update'): - g.editable = True - g.edit_route_name = 'batch_row.update' - g.edit_route_kwargs = route_kwargs - - if self.request.has_perm('batch_rows.delete'): - g.deletable = True - g.delete_route_name = 'batch_row.delete' - g.delete_route_kwargs = route_kwargs - - return g - - def render_kwargs(self): - return {'batch': batch} - - grid = BatchRowsGrid(request) - grid.batch = batch - return grid - - -def batch_rows_grid(request): - result = BatchRowsGrid(request) - if isinstance(result, HTTPFound): - return result - return result() - - -def batch_rows_delete(request): - grid = BatchRowsGrid(request) - grid._filter_config = grid.filter_config() - rows = grid.make_query() - count = rows.count() - rows.delete(synchronize_session=False) - grid.batch.rowcount -= count - request.session.flash("Deleted %d rows from batch." % count) - return HTTPFound(location=request.route_url('batch.rows', uuid=grid.batch.uuid)) - - -def batch_row_crud(request, attr): - batch_uuid = request.matchdict['batch_uuid'] - batch = Session.query(Batch).get(batch_uuid) - if not batch: - return HTTPFound(location=request.route_url('batches')) - - row_uuid = request.matchdict['uuid'] - row = Session.query(batch.rowclass).get(row_uuid) - if not row: - return HTTPFound(location=request.route_url('batch.read', uuid=batch.uuid)) - - class BatchRowCrud(CrudView): - - mapped_class = batch.rowclass - pretty_name = "Batch Row" - - @property - def home_url(self): - return self.request.route_url('batch.rows', uuid=batch.uuid) - - @property - def cancel_url(self): - return self.home_url - - def fieldset(self, model): - fs = self.make_fieldset(model) - - include = [fs.ordinal.label("Row Number").readonly()] - for column in batch.columns: - field = getattr(fs, column.name) - field = field_with_renderer(field, column) - field = field.label(column.display_name) - include.append(field) - - fs.configure(include=include) - return fs - - def flash_delete(self, row): - self.request.session.flash("Batch Row %d has been deleted." - % row.ordinal) - - def post_delete(self, model): - batch.rowcount -= 1 - - crud = BatchRowCrud(request) - return getattr(crud, attr)() - -def batch_row_read(request): - return batch_row_crud(request, 'read') - -def batch_row_update(request): - return batch_row_crud(request, 'update') - -def batch_row_delete(request): - return batch_row_crud(request, 'delete') - - -def includeme(config): - - config.add_route('batch.rows', '/batches/{uuid}/rows') - config.add_view(batch_rows_grid, route_name='batch.rows', - renderer='/batches/rows/index.mako', - permission='batches.read') - - config.add_route('batch.rows.delete', '/batches/{uuid}/rows/delete') - config.add_view(batch_rows_delete, route_name='batch.rows.delete', - permission='batch_rows.delete') - - config.add_route('batch_row.read', '/batches/{batch_uuid}/{uuid}') - config.add_view(batch_row_read, route_name='batch_row.read', - renderer='/batches/rows/crud.mako', - permission='batch_rows.read') - - config.add_route('batch_row.update', '/batches/{batch_uuid}/{uuid}/edit') - config.add_view(batch_row_update, route_name='batch_row.update', - renderer='/batches/rows/crud.mako', - permission='batch_rows.update') - - config.add_route('batch_row.delete', '/batches/{batch_uuid}/{uuid}/delete') - config.add_view(batch_row_delete, route_name='batch_row.delete', - permission='batch_rows.delete') diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py index c7d42b67..7afcc567 100644 --- a/tailbone/views/bouncer.py +++ b/tailbone/views/bouncer.py @@ -1,49 +1,41 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 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 -import formalchemy -from pyramid.response import FileResponse -from webhelpers.html import literal +from webhelpers2.html import HTML, tags -from tailbone import newgrids as grids -from tailbone.db import Session from tailbone.views import MasterView -from tailbone.forms.renderers.bouncer import BounceMessageFieldRenderer -class EmailBouncesView(MasterView): +class EmailBounceView(MasterView): """ Master view for email bounces. """ @@ -52,72 +44,115 @@ class EmailBouncesView(MasterView): url_prefix = '/email-bounces' creatable = False editable = False + downloadable = True + + labels = { + 'config_key': "Source", + 'bounce_recipient_address': "Bounced To", + 'intended_recipient_address': "Intended For", + } + + grid_columns = [ + 'config_key', + 'bounced', + 'bounce_recipient_address', + 'intended_recipient_address', + 'processed_by', + ] def __init__(self, request): - super(EmailBouncesView, self).__init__(request) - self.handler_options = [('', '(any)')] + sorted(get_profile_keys(self.rattail_config)) + 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): - g.joiners['processed_by'] = lambda q: q.outerjoin(model.User) + super().configure_grid(g) + model = self.model + + g.filters['config_key'].set_choices(self.handler_options) g.filters['config_key'].default_active = True g.filters['config_key'].default_verb = 'equal' - g.filters['config_key'].label = "Source" - g.filters['config_key'].set_value_renderer(grids.filters.ChoiceValueRenderer(self.handler_options)) - g.filters['bounce_recipient_address'].label = "Bounced To" - g.filters['intended_recipient_address'].label = "Intended For" + 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.configure( - include=[ - g.config_key.label("Source"), - g.bounced, - g.bounce_recipient_address.label("Bounced To"), - g.intended_recipient_address.label("Intended For"), - g.processed_by, - ], - readonly=True) - def configure_fieldset(self, fs): - bounce = fs.model - handler = self.get_handler(bounce) - fs.append(formalchemy.Field('message', - value=handler.msgpath(bounce), - renderer=BounceMessageFieldRenderer.new(self.request, handler))) - fs.append(formalchemy.Field('links', - value=list(handler.make_links(Session(), bounce.intended_recipient_address)), - renderer=LinksFieldRenderer)) - fs.configure( - include=[ - fs.config_key.label("Source"), - fs.message, - fs.bounced, - fs.bounce_recipient_address.label("Bounced To"), - fs.intended_recipient_address.label("Intended For"), - fs.links, - fs.processed, - fs.processed_by, - ], - readonly=True) + # 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") + + g.set_link('bounced') + g.set_link('intended_recipient_address') + + def configure_form(self, f): + super().configure_form(f) + bounce = f.model_instance + f.set_renderer('message', self.render_message_file) + f.set_renderer('links', self.render_links) + f.fields = [ + 'config_key', + 'message', + 'bounced', + 'bounce_recipient_address', + 'intended_recipient_address', + 'links', + 'processed', + 'processed_by', + ] if not bounce.processed: - del fs.processed - del fs.processed_by + f.remove_field('processed') + f.remove_field('processed_by') + + def render_links(self, bounce, field): + handler = self.get_handler(bounce) + value = list(handler.make_links(self.Session(), bounce.intended_recipient_address)) + if not value: + return "n/a" + + links = [] + for link in value: + label = HTML.literal("{}: ".format(link.type)) + anchor = tags.link_to(link.title, link.url, target='_blank') + links.append(HTML.tag('li', label + anchor)) + + return HTML.tag('ul', HTML.literal('').join(links)) + + def render_message_file(self, bounce, field): + handler = self.get_handler(bounce) + path = handler.msgpath(bounce) + if not path: + return "" + + url = self.get_action_url('download', bounce) + return self.render_file_field(path, url) def template_kwargs_view(self, **kwargs): bounce = kwargs['instance'] kwargs['bounce'] = bounce handler = self.get_handler(bounce) kwargs['handler'] = handler - with open(handler.msgpath(bounce), 'rb') as f: - kwargs['message'] = f.read() + path = handler.msgpath(bounce) + if os.path.exists(path): + with open(path, 'rb') as f: + # 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. @@ -126,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*. @@ -136,22 +172,17 @@ 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) # mark bounce as processed config.add_route('emailbounces.process', '/email-bounces/{uuid}/process') @@ -167,29 +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() - -class LinksFieldRenderer(formalchemy.FieldRenderer): - - def render_readonly(self, **kwargs): - value = self.raw_value - if not value: - return 'n/a' - html = literal('<ul>') - for link in value: - html += literal('<li>{0}: <a href="{1}" target="_blank">{2}</a></li>'.format( - link.type, link.url, link.title)) - html += literal('</ul>') - return html + 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 892d2f95..109c80a7 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -1,23 +1,23 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ @@ -28,54 +28,120 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView, AutocompleteView -from tailbone.views.continuum import VersionView, version_defaults +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(BrandView, self).configure_grid(g) + + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'name' - g.configure( - include=[ - g.name, - ], - readonly=True) + g.set_sort_defaults('name') + g.set_link('name') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.name, - ]) - return fs + # confirmed + g.set_type('confirmed', 'boolean') + + def get_row_data(self, brand): + return self.Session.query(model.Product)\ + .filter(model.Product.brand == brand) + + 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) -class BrandVersionView(VersionView): - """ - View which shows version history for a brand. - """ - parent_class = model.Brand - route_model_view = 'brands.view' +def defaults(config, **kwargs): + base = globals() - -class BrandsAutocomplete(AutocompleteView): - - mapped_class = model.Brand - fieldname = 'name' + 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) - version_defaults(config, BrandVersionView, 'brand') + defaults(config) diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index bfb2b1c7..941257b8 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -1,23 +1,23 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ @@ -28,50 +28,94 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model +from tailbone import forms from tailbone.views import MasterView -from tailbone.views.continuum import VersionView, version_defaults -class CategoriesView(MasterView): +class CategoryView(MasterView): """ Master view for the Category class. """ model_class = model.Category model_title_plural = "Categories" route_prefix = 'categories' + has_versions = True + results_downloadable = True + + grid_columns = [ + 'code', + 'name', + 'department', + ] + + form_fields = [ + 'code', + 'name', + 'department', + ] def configure_grid(self, g): + super(CategoryView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'number' - g.configure( - include=[ - g.number, - g.name, - g.department, - ], - readonly=True) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.number, - fs.name, - fs.department, - ]) - return fs + 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('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 -class CategoryVersionView(VersionView): - """ - View which shows version history for a category. - """ - parent_class = model.Category - model_title_plural = "Categories" - route_model_list = 'categories' - route_model_view = 'categories.view' +def defaults(config, **kwargs): + base = globals() + + CategoryView = kwargs.get('CategoryView', base['CategoryView']) + CategoryView.defaults(config) def includeme(config): - CategoriesView.defaults(config) - version_defaults(config, CategoryVersionView, 'category', template_prefix='/categories') + defaults(config) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index b72ecbd8..f4d98c05 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -1,66 +1,378 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Various common views """ -from __future__ import unicode_literals, absolute_import +import os +import warnings +from collections import OrderedDict -from rattail.mail import send_email - -import formencode -from pyramid import httpexceptions -from formencode import validators -from pyramid_simpleform import Form +from rattail.batch import consume_batch_id +from rattail.util import get_pkg_version, simple_error +from rattail.files import resource_path from tailbone import forms +from tailbone.forms.common import Feedback +from tailbone.db import Session +from tailbone.views import View +from tailbone.util import set_app_theme +from tailbone.config import global_help_url -class Feedback(formencode.Schema): +class CommonView(View): """ - Form schema for user feedback. + Base class for common views; override as needed. """ - allow_extra_fields = True - user = forms.validators.ValidUser() - user_name = validators.NotEmpty() - message = validators.NotEmpty() + robots_txt_path = resource_path('tailbone.static:robots.txt') + + def home(self, **kwargs): + """ + Home page view. + """ + app = self.get_rattail_app() + + # maybe auto-redirect anons to login + if not self.request.user: + 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): + """ + Returns a basic 'robots.txt' response + """ + with open(self.robots_txt_path, 'rt') as f: + content = f.read() + response = self.request.response + response.text = content + response.content_type = 'text/plain' + return response + + def get_project_title(self): + app = self.get_rattail_app() + return app.get_title() + + def get_project_version(self): + + # TODO: deprecate this + if hasattr(self, 'project_version'): + return self.project_version + + app = self.get_rattail_app() + return app.get_version() + + def exception(self): + """ + Generic exception view + """ + return {'project_title': self.get_project_title()} + + def about(self): + """ + Generic view to show "about project" info page. + """ + app = self.get_rattail_app() + return { + '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): + """ + 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')), + ]) + + 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 handle the user feedback form. + """ + app = self.get_rattail_app() + model = self.model + schema = Feedback().bind(session=Session()) + form = forms.Form(schema=schema, request=self.request) + if form.validate(): + 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) + data['client_ip'] = self.request.client_addr + app.send_email('user_feedback', data=data) + return {'ok': True} + dform = form.make_deform_form() + return {'error': str(dform.error)} + + def consume_batch_id(self): + """ + Consume next batch ID from the PG sequence, and display via flash message. + """ + batch_id = consume_batch_id(Session()) + self.request.session.flash("Batch ID has been consumed: {:08d}".format(batch_id)) + return self.redirect(self.request.get_referrer()) + + def bogus_error(self): + """ + A special view which simply raises an error, for the sake of testing + uncaught exception handling. + """ + 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) + + @classmethod + def _defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + + # auto-correct URLs which require trailing slash + config.add_notfound_view(cls, attr='notfound', append_slash=True) + + # exception + if rattail_config and rattail_config.production(): + config.add_exception_view(cls, attr='exception', renderer='/exception.mako') + + # 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') + + # robots.txt + config.add_route('robots.txt', '/robots.txt') + config.add_view(cls, attr='robots_txt', route_name='robots.txt') + + # about + config.add_route('about', '/about') + config.add_view(cls, attr='about', route_name='about', renderer='/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', permission='common.feedback') + + # consume batch ID + config.add_tailbone_permission('common', 'common.consume_batch_id', + "Consume new Batch ID") + config.add_route('consume_batch_id', '/consume-batch-id') + config.add_view(cls, attr='consume_batch_id', route_name='consume_batch_id') + + # bogus error + 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 feedback(request): - """ - Generic view to present/handle the user feedback form. - """ - form = Form(request, schema=Feedback) - if form.validate(): - data = dict(form.data) - if data['user']: - data['user_url'] = request.route_url('users.view', uuid=data['user'].uuid) - send_email(request.rattail_config, 'user_feedback', data=data) - request.session.flash("Thank you for your feedback.") - return httpexceptions.HTTPFound(location=form.data['referrer']) - return {'form': forms.FormRenderer(form)} +def defaults(config, **kwargs): + base = globals() + + CommonView = kwargs.get('CommonView', base['CommonView']) + CommonView.defaults(config) def includeme(config): - config.add_route('feedback', '/feedback') - config.add_view(feedback, route_name='feedback', renderer='/feedback.mako') + defaults(config) diff --git a/tailbone/views/continuum.py b/tailbone/views/continuum.py deleted file mode 100644 index 9fa95706..00000000 --- a/tailbone/views/continuum.py +++ /dev/null @@ -1,229 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Continuum Version Views -""" - -from __future__ import unicode_literals - -import sqlalchemy as sa -import sqlalchemy_continuum as continuum - -from rattail.db import model -from rattail.db.continuum import model_transaction_query - -import formalchemy -from pyramid.httpexceptions import HTTPNotFound - -from tailbone.db import Session -from tailbone.views import PagedAlchemyGridView, View - - -class VersionView(PagedAlchemyGridView): - """ - View which shows version history for a model instance. - """ - - @property - def parent_class(self): - """ - Model class which is "parent" to the version class. - """ - raise NotImplementedError("Please set `parent_class` on your `VersionView` subclass.") - - @property - def child_classes(self): - """ - Model class(es) which are "children" to the version's parent class. - """ - return [] - - @property - def model_title(self): - """ - Human-friendly title for the parent model class. - """ - return self.parent_class.__name__ - - @property - def model_title_plural(self): - """ - Plural version of the human-friendly title for the parent model class. - """ - return '{0}s'.format(self.model_title) - - @property - def prefix(self): - return self.parent_class.__name__.lower() - - @property - def config_prefix(self): - return self.prefix - - @property - def transaction_class(self): - return continuum.transaction_class(self.parent_class) - - @property - def mapped_class(self): - return self.transaction_class - - @property - def version_class(self): - return continuum.version_class(self.parent_class) - - @property - def route_model_list(self): - return '{0}s'.format(self.prefix) - - @property - def route_model_view(self): - return self.prefix - - def join_map(self): - return { - 'user': - lambda q: q.outerjoin(model.User, self.transaction_class.user_uuid == model.User.uuid), - } - - def sort_config(self): - return self.make_sort_config(sort='issued_at', dir='desc') - - def sort_map(self): - return self.make_sort_map('issued_at', 'remote_addr', - user=self.sorter(model.User.username)) - - def transaction_query(self, session=Session): - uuid = self.request.matchdict['uuid'] - return model_transaction_query(session, uuid, self.parent_class, - child_classes=self.child_classes) - - def make_query(self, session=Session): - query = self.transaction_query(session) - return self.modify_query(query) - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.issued_at.label("When"), - g.user.label("Who"), - g.remote_addr.label("Client IP"), - ], - readonly=True) - g.viewable = True - g.view_route_name = '{0}.version'.format(self.prefix) - g.view_route_kwargs = self.view_route_kwargs - return g - - def render_kwargs(self): - instance = Session.query(self.parent_class).get(self.request.matchdict['uuid']) - return {'model_title': self.model_title, - 'model_title_plural': self.model_title_plural, - 'model_instance': instance, - 'route_model_list': self.route_model_list, - 'route_model_view': self.route_model_view} - - def view_route_kwargs(self, transaction): - return {'uuid': self.request.matchdict['uuid'], - 'transaction_id': transaction.id} - - def list(self): - """ - View which shows the version history list for a model instance. - """ - return self() - - def details(self): - """ - View which shows the change details of a model version. - """ - kwargs = self.render_kwargs() - uuid = self.request.matchdict['uuid'] - transaction_id = self.request.matchdict['transaction_id'] - transaction = Session.query(self.transaction_class).get(transaction_id) - if not transaction: - raise HTTPNotFound - - version = Session.query(self.version_class).get((uuid, transaction_id)) - - def normalize_child_classes(): - classes = [] - for cls in self.child_classes: - if not isinstance(cls, tuple): - cls = (cls, 'uuid') - classes.append(cls) - return classes - - versions = [] - if version: - versions.append(version) - for model_class, attr in normalize_child_classes(): - if isinstance(model_class, type) and issubclass(model_class, model.Base): - cls = continuum.version_class(model_class) - ver = Session.query(cls).filter_by(transaction_id=transaction_id, **{attr: uuid}).first() - if ver: - versions.append(ver) - - previous_transaction = self.transaction_query()\ - .order_by(self.transaction_class.id.desc())\ - .filter(self.transaction_class.id < transaction.id)\ - .first() - - next_transaction = self.transaction_query()\ - .order_by(self.transaction_class.id.asc())\ - .filter(self.transaction_class.id > transaction.id)\ - .first() - - kwargs.update({ - 'route_prefix': self.prefix, - 'version': version, - 'transaction': transaction, - 'versions': versions, - 'parent_class': continuum.parent_class, - 'previous_transaction': previous_transaction, - 'next_transaction': next_transaction, - }) - - return kwargs - - -def version_defaults(config, VersionView, prefix, template_prefix=None): - """ - Apply default route/view configuration for the given ``VersionView``. - """ - if template_prefix is None: - template_prefix = '/{0}s'.format(prefix) - template_prefix = template_prefix.rstrip('/') - - # list changesets - config.add_route('{0}.versions'.format(prefix), '/{0}/{{uuid}}/changesets/'.format(prefix)) - config.add_view(VersionView, attr='list', route_name='{0}.versions'.format(prefix), - renderer='{0}/versions/index.mako'.format(template_prefix), - permission='{0}.versions.view'.format(prefix)) - - # view changeset - config.add_route('{0}.version'.format(prefix), '/{0}/{{uuid}}/changeset/{{transaction_id}}'.format(prefix)) - config.add_view(VersionView, attr='details', route_name='{0}.version'.format(prefix), - renderer='{0}/versions/view.mako'.format(template_prefix), - permission='{0}.versions.view'.format(prefix)) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index b0a17c63..88b2519f 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -1,63 +1,116 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Base View Class """ -from __future__ import unicode_literals, absolute_import - -from rattail.db import model +import os 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 + if getattr(request, 'user', None): + # TODO: why is the user sometimes not attached to session? + # (this has only been seen on mobile, when creating a new ordering batch) + if request.user not in Session(): + request.user = Session.merge(request.user) + if not request.user.active: + headers = logout_user(request) + raise self.redirect(request.route_url('home')) + + config = self.rattail_config + if config: + self.config = config + self.app = self.config.get_app() + self.model = self.app.model + self.enum = self.app.enum + @property def rattail_config(self): """ Reference to the effective Rattail config object. """ - return self.request.rattail_config + 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() + def late_login_user(self): """ Returns the :class:`rattail:rattail.db.model.User` instance corresponding to the "late login" form data (if any), or ``None``. """ + model = self.model if self.request.method == 'POST': uuid = self.request.POST.get('late-login-user') if uuid: - return Session.query(model.User).get(uuid) + return Session.get(model.User, uuid) + + def user_is_protected(self, user): + """ + 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): """ @@ -65,21 +118,64 @@ class View(object): """ return httpexceptions.HTTPFound(location=url, **kwargs) - def render_progress(self, kwargs): + def progress_loop(self, 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): """ Render the progress page, with given kwargs as context. """ - return render_to_response('/progress.mako', kwargs, request=self.request) + if not template: + template = '/progress.mako' + kwargs['progress'] = progress + kwargs.setdefault('can_cancel', True) + return render_to_response(template, kwargs, request=self.request) + def json_response(self, data): + """ + Convenience method to return a JSON response. + """ + return render_to_response('json', data, + request=self.request) -def fake_error(request): - """ - View which raises a fake error, to test exception handling. - """ - raise Exception("Fake error, to test exception handling.") + 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.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 includeme(config): - config.add_route('fake_error', '/fake-error') - config.add_view(fake_error, route_name='fake_error', - permission='admin') + 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/crud.py b/tailbone/views/crud.py deleted file mode 100644 index 31f03b7d..00000000 --- a/tailbone/views/crud.py +++ /dev/null @@ -1,283 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -CRUD View -""" - -from __future__ import unicode_literals - -try: - from sqlalchemy.inspection import inspect -except ImportError: - inspect = None - from sqlalchemy.orm import class_mapper - -import sqlalchemy as sa -from sqlalchemy_continuum import transaction_class, version_class -from sqlalchemy_continuum.utils import is_versioned - -from rattail.db import model -from rattail.db.continuum import count_versions, model_transaction_query - -from pyramid.httpexceptions import HTTPFound, HTTPNotFound - -from .core import View - -from ..forms import AlchemyForm -from formalchemy import FieldSet - -from edbob.util import prettify - -from tailbone.db import Session - - -__all__ = ['CrudView'] - - -class CrudView(View): - - readonly = False - allow_successive_creates = False - update_cancel_route = None - child_version_classes = [] - - @property - def mapped_class(self): - raise NotImplementedError - - @property - def pretty_name(self): - return self.mapped_class.__name__ - - @property - def home_route(self): - raise NotImplementedError - - @property - def home_url(self): - return self.request.route_url(self.home_route) - - @property - def cancel_route(self): - return self.home_route - - @property - def cancel_url(self): - return self.request.route_url(self.cancel_route) - - def make_fieldset(self, model, **kwargs): - kwargs.setdefault('session', Session()) - kwargs.setdefault('request', self.request) - fieldset = FieldSet(model, **kwargs) - fieldset.prettify = prettify - return fieldset - - def fieldset(self, model): - return self.make_fieldset(model) - - def make_form(self, model, form_factory=AlchemyForm, **kwargs): - fieldset = self.fieldset(model) - kwargs.setdefault('pretty_name', self.pretty_name) - kwargs.setdefault('action_url', self.request.current_route_url()) - if self.updating and self.update_cancel_route: - kwargs.setdefault('cancel_url', self.request.route_url( - self.update_cancel_route, uuid=model.uuid)) - else: - kwargs.setdefault('cancel_url', self.cancel_url) - kwargs.setdefault('creating', self.creating) - kwargs.setdefault('updating', self.updating) - form = form_factory(self.request, fieldset, **kwargs) - - if form.creating: - if hasattr(self, 'create_label'): - form.create_label = self.create_label - if self.allow_successive_creates: - form.allow_successive_creates = True - if hasattr(self, 'successive_create_label'): - form.successive_create_label = self.successive_create_label - - return form - - def form(self, model): - return self.make_form(model) - - def save_form(self, form): - form.save() - - def crud(self, model, readonly=False): - - self.readonly = readonly - if self.readonly: - self.creating = False - self.updating = False - else: - self.creating = model is self.mapped_class - self.updating = not self.creating - - result = self.pre_crud(model) - if result is not None: - return result - - form = self.form(model) - form.readonly = self.readonly - if not self.readonly and self.request.method == 'POST': - - if form.validate(): - self.save_form(form) - - result = self.post_save(form) - if result is not None: - return result - - if form.creating: - self.flash_create(form.fieldset.model) - else: - self.flash_update(form.fieldset.model) - - if (form.creating and form.allow_successive_creates - and self.request.params.get('create_and_continue')): - return HTTPFound(location=self.request.current_route_url()) - - if form.creating: - url = self.post_create_url(form) - else: - url = self.post_update_url(form) - return HTTPFound(location=url) - - self.validation_failed(form) - - result = self.post_crud(model, form) - if result is not None: - return result - - kwargs = self.template_kwargs(form) - kwargs['form'] = form - return kwargs - - def pre_crud(self, model): - pass - - def post_crud(self, model, form): - pass - - def template_kwargs(self, form): - if not form.creating and is_versioned(self.mapped_class): - return {'version_count': self.count_versions()} - return {} - - def count_versions(self): - query = self.transaction_query() - return query.count() - - def transaction_query(self, parent_class=None, child_classes=None): - uuid = self.request.matchdict['uuid'] - if parent_class is None: - parent_class = self.mapped_class - if child_classes is None: - child_classes = self.child_version_classes - return model_transaction_query(Session, uuid, parent_class, child_classes=child_classes) - - def post_save(self, form): - pass - - def post_save_url(self, form): - return self.home_url - - def post_create_url(self, form): - return self.post_save_url(form) - - def post_update_url(self, form): - return self.post_save_url(form) - - def validation_failed(self, form): - pass - - def flash_create(self, model): - self.request.session.flash("%s \"%s\" has been created." % - (self.pretty_name, model)) - - def flash_delete(self, model): - self.request.session.flash("%s \"%s\" has been deleted." % - (self.pretty_name, model)) - - def flash_update(self, model): - self.request.session.flash("%s \"%s\" has been updated." % - (self.pretty_name, model)) - - def create(self): - return self.crud(self.mapped_class) - - def get_model_from_request(self): - if inspect: - mapper = inspect(self.mapped_class) - else: - mapper = class_mapper(self.mapped_class) - assert len(mapper.primary_key) == 1 - key = self.request.matchdict[mapper.primary_key[0].key] - return self.get_model(key) - - def get_model(self, key): - model = Session.query(self.mapped_class).get(key) - return model - - def read(self): - model = self.get_model_from_request() - if not model: - return HTTPNotFound() - return self.crud(model, readonly=True) - - def update(self): - model = self.get_model_from_request() - if not model: - return HTTPNotFound() - return self.crud(model) - - def delete(self): - """ - View for deleting a record. Derived classes shouldn't override this, - but see also :meth:`pre_delete()` and :meth:`post_delete()`. - """ - model = self.get_model_from_request() - if not model: - return HTTPNotFound() - - # Let derived classes prep for (or cancel) deletion. - result = self.pre_delete(model) - if result is not None: - return result - - # Flush the deletion immediately so that we know it will succeed prior - # to setting a flash message etc. - Session.delete(model) - Session.flush() - - # Derived classes can do extra things here; set flash and go home. - self.post_delete(model) - self.flash_delete(model) - return HTTPFound(location=self.home_url) - - def pre_delete(self, model): - pass - - def post_delete(self, model): - pass diff --git a/tailbone/views/customergroups.py b/tailbone/views/customergroups.py index 1fecd303..98cea8e0 100644 --- a/tailbone/views/customergroups.py +++ b/tailbone/views/customergroups.py @@ -1,23 +1,23 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ @@ -32,30 +32,34 @@ from tailbone.db import Session from tailbone.views import MasterView -class CustomerGroupsView(MasterView): +class CustomerGroupView(MasterView): """ Master view for the CustomerGroup class. """ model_class = model.CustomerGroup model_title = "Customer Group" + labels = { + 'id': "ID", + } + + grid_columns = [ + 'id', + 'name', + ] + + form_fields = [ + 'id', + 'name', + ] + def configure_grid(self, g): + super(CustomerGroupView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'name' - g.configure( - include=[ - g.id.label("ID"), - g.name, - ], - readonly=True) - - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id.label("ID"), - fs.name, - ]) + g.set_sort_defaults('name') + g.set_link('id') + g.set_link('name') def before_delete(self, group): # First remove customer associations. @@ -72,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 bd5cf946..7e49ccef 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -1,96 +1,256 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Customer Views """ -from __future__ import unicode_literals, absolute_import - -import re +from collections import OrderedDict import sqlalchemy as sa from sqlalchemy import orm +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 MasterView, AutocompleteView +from tailbone.views import MasterView -from rattail import enum -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 + 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 = [ + '_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().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.filters['id'].label = "ID" - 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)()) + # 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) - g.default_sortkey = 'name' + # 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") - g.configure( - include=[ - g.id.label("ID"), - g.name, - g.phone.label("Phone Number"), - g.email.label("Email Address"), - ], - readonly=True) + # 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('name') + g.set_link('person') + g.set_link('email') + + def default_view_url(self): + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): + app = self.get_rattail_app() + + def url(customer, i): + person = app.get_person(customer) + if person: + return self.request.route_url( + 'people.view_profile', uuid=person.uuid, + _anchor='customer') + return self.get_action_url('view', customer) + + return url + + return super().default_view_url() + + def should_link_straight_to_profile(self): + return self.rattail_config.getbool('rattail', + 'customers.straight_to_profile', + default=False) + + def grid_extra_class(self, customer, i): + if self.get_expose_active_in_pos(): + if not customer.active_in_pos: + return 'warning' def get_instance(self): try: - instance = super(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 @@ -101,72 +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 configure_fieldset(self, fs): - fs.email_preference.set(renderer=forms.EnumFieldRenderer(enum.EMAIL_PREFERENCE)) - fs.configure( - include=[ - fs.id.label("ID"), - fs.name, - fs.phone.label("Phone Number").readonly(), - fs.email.label("Email Address").readonly(), - fs.email_preference, - ]) + def configure_form(self, f): + super().configure_form(f) + customer = f.model_instance + permission_prefix = self.get_permission_prefix() + + # account_holder + if self.creating: + f.remove_field('account_holder') + else: + f.set_readonly('account_holder') + f.set_renderer('account_holder', self.render_person) + + # default_email + f.set_renderer('default_email', self.render_default_email) + if not self.creating and customer.emails: + f.set_default('default_email', customer.emails[0].address) + + # default_phone + f.set_renderer('default_phone', self.render_default_phone) + if not self.creating and customer.phones: + f.set_default('default_phone', customer.phones[0].number) + + # 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): + 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) + + @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 { @@ -176,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 new file mode 100644 index 00000000..78a3d3ab --- /dev/null +++ b/tailbone/views/custorders/__init__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 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/>. +# +################################################################################ +""" +Customer Order Views +""" + +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 new file mode 100644 index 00000000..e7edf3aa --- /dev/null +++ b/tailbone/views/custorders/items.py @@ -0,0 +1,689 @@ +# -*- 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/>. +# +################################################################################ +""" +Customer order item views +""" + +import datetime + +from sqlalchemy import orm + +from rattail.db.model import CustomerOrderItem + +from webhelpers2.html import HTML, tags + +from tailbone.views import MasterView +from tailbone.util import raw_datetime, csrf_token + + +class CustomerOrderItemView(MasterView): + """ + Master view for customer order items + """ + 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', + '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', + ] + + 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().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.set_sorter('person', model.Person.display_name) + g.set_filter('person', model.Person.display_name, + default_active=True, default_verb='contains') + + # product_key + field = self.get_product_key_field() + g.set_renderer(field, lambda item, field: getattr(item, f'product_{field}')) + + # product_* + g.set_label('product_brand', "Brand") + g.set_link('product_brand') + g.set_label('product_description', "Description") + g.set_link('product_description') + g.set_label('product_size', "Size") + + # "numbers" + g.set_type('case_quantity', 'quantity') + g.set_type('order_quantity', 'quantity') + g.set_type('total_price', 'currency') + # TODO: deprecate / remove these + g.set_type('cases_ordered', 'quantity') + g.set_type('units_ordered', 'quantity') + + # order_uom + # nb. this is not relevant if "case orders only" + if not self.batch_handler.allow_unit_orders(): + g.remove('order_uom') + else: + g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) + + # order_created + g.set_renderer('order_created', self.render_order_created) + g.set_sorter('order_created', model.CustomerOrder.created) + g.set_sort_defaults('order_created', 'desc') + + # status_code + g.set_renderer('status_code', self.render_status_code_column) + + # abbreviate some labels, only in grid header + g.set_label('case_quantity', "Case Qty") + g.filters['case_quantity'].label = "Case Quantity" + g.set_label('order_quantity', "Order Qty") + g.filters['order_quantity'].label = "Order Quantity" + g.set_label('department_name', "Department") + g.filters['department_name'].label = "Department Name" + g.set_label('total_price', "Total") + g.filters['total_price'].label = "Total Price" + g.set_label('order_created', "Ordered") + if 'order_created' in g.filters: + g.filters['order_created'].label = "Order Created" + + def render_order_id(self, item, field): + return item.order.id + + 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): + app = self.get_rattail_app() + value = app.localtime(item.order.created, from_utc=True) + return raw_datetime(self.rattail_config, value) + + def render_status_code_column(self, item, field): + text = self.enum.CUSTORDER_ITEM_STATUS.get(item.status_code, + str(item.status_code)) + if item.status_text: + return HTML.tag('span', title=item.status_text, c=[text]) + return text + + def configure_form(self, f): + super().configure_form(f) + item = f.model_instance + + # order + f.set_renderer('order', self.render_order) + + # contact + if self.batch_handler.new_order_requires_customer(): + f.remove('person') + else: + f.remove('customer') + + # product key + key = self.get_product_key_field() + f.set_renderer(key, lambda item, field: getattr(item, f'product_{key}')) + + # (pending) product + f.set_renderer('product', self.render_product) + f.set_renderer('pending_product', self.render_pending_product) + 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): + defaults(config) diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py new file mode 100644 index 00000000..b1a9831a --- /dev/null +++ b/tailbone/views/custorders/orders.py @@ -0,0 +1,1219 @@ +# -*- 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/>. +# +################################################################################ +""" +Customer Order Views +""" + +import decimal +import logging + +from sqlalchemy import orm + +from rattail.db.model import CustomerOrder, CustomerOrderItem +from rattail.util import simple_error +from rattail.batch import get_batch_handler + +from webhelpers2.html import tags, HTML + +from tailbone.views import MasterView + + +log = logging.getLogger(__name__) + + +class CustomerOrderView(MasterView): + """ + Master view for customer orders + """ + model_class = CustomerOrder + route_prefix = 'custorders' + editable = False + configurable = True + + labels = { + 'id': "Order ID", + 'status_code': "Status", + } + + grid_columns = [ + 'id', + 'customer', + 'person', + '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().configure_grid(g) + model = self.app.model + + # id + g.set_link('id') + g.filters['id'].default_active = True + g.filters['id'].default_verb = 'equal' + + # import ipdb; ipdb.set_trace() + + # customer or person + if self.batch_handler.new_order_requires_customer(): + g.remove('person') + g.set_link('customer') + g.set_joiner('customer', lambda q: q.outerjoin(model.Customer)) + g.set_sorter('customer', model.Customer.name) + g.filters['customer'] = g.make_filter('customer', model.Customer.name, + label="Customer Name", + default_active=True, + default_verb='contains') + else: + g.remove('customer') + g.set_link('person') + g.set_joiner('person', lambda q: q.outerjoin(model.Person)) + g.set_sorter('person', model.Person.display_name) + g.filters['person'] = g.make_filter('person', model.Person.display_name, + label="Person Name", + default_active=True, + default_verb='contains') + + # status_code + g.set_enum('status_code', self.enum.CUSTORDER_STATUS) + + # created + g.set_sort_defaults('created', 'desc') + + 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_sort_defaults('sequence') + + 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): + defaults(config) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 3739483e..2b955b5f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -1,96 +1,467 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 """ -from __future__ import unicode_literals, absolute_import - +import json import subprocess import logging -from rattail.db import model -from rattail.config import parse_list +import sqlalchemy as sa + +from rattail.db.model import DataSyncChange +from rattail.datasync.util import purge_datasync_settings +from rattail.util import simple_error + +from webhelpers2.html import tags from tailbone.views import MasterView +from tailbone.util import raw_datetime +from tailbone.config import should_expose_websockets log = logging.getLogger(__name__) -class DataSyncChangesView(MasterView): +class DataSyncThreadView(MasterView): """ - Master view for the DataSyncChange model. - """ - model_class = model.DataSyncChange - model_title = "DataSync Change" - url_prefix = '/datasync/changes' - permission_prefix = 'datasync' + Master view for DataSync itself. - creatable = False + 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 - def configure_grid(self, g): - g.default_sortkey = 'obtained' - g.configure( - include=[ - g.source, - g.payload_type, - g.payload_key, - g.deletion, - g.obtained, - g.consumer, - ], - readonly=True) + 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): - # TODO: Add better validation (e.g. CSRF) here? - if self.request.method == 'POST': - cmd = parse_list(self.rattail_config.require('tailbone', 'datasync.restart')) - 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.") + 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: - self.request.session.flash("DataSync daemon could not be restarted; result was: {}".format(result), 'error') - return self.redirect(self.request.route_url('datasyncchanges')) + 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): - - # 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") - 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 = DataSyncChange + url_prefix = '/datasync/changes' + permission_prefix = 'datasync_changes' + creatable = False + bulk_deletable = True + + labels = { + 'batch_id': "Batch ID", + } + + grid_columns = [ + 'source', + 'batch_id', + 'batch_sequence', + 'payload_type', + 'payload_key', + 'deletion', + 'obtained', + '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().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 template_kwargs_index(self, **kwargs): + kwargs['allow_filemon_restart'] = bool(self.rattail_config.get('tailbone', 'filemon.restart')) + return kwargs + + def configure_form(self, f): + super().configure_form(f) + + f.set_readonly('obtained') + + +# TODO: deprecate / remove this +DataSyncChangesView = DataSyncChangeView + + +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 d0a3f33d..47de8dca 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -1,137 +1,254 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Department Views """ -from __future__ import unicode_literals, absolute_import +from rattail.db.model import Department, Product -from rattail.db import model +from webhelpers2.html import HTML -from tailbone.views import MasterView, AutocompleteView, AlchemyGridView -from tailbone.views.continuum import VersionView, version_defaults -from tailbone.newgrids import AlchemyGrid, GridAction +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().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.configure( - include=[ - g.number, - g.name, - ], - readonly=True) + g.set_link('name') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.number, - fs.name, - ]) - return fs + g.set_type('product', 'boolean') + g.set_type('personnel', 'boolean') + + def configure_form(self, f): + super().configure_form(f) + + f.remove_field('subdepartments') + + 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') + + # tax + if self.creating: + # TODO: make this editable instead + f.remove('tax') + else: + f.set_renderer('tax', self.render_tax) + # TODO: make this editable + f.set_readonly('tax') + + # default_custorder_discount + f.set_type('default_custorder_discount', 'percent') + + def render_employees(self, department, field): + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + factory = self.get_grid_factory() + g = factory( + 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'] - if department.employees: + department_employees = sorted(department.employees, key=str) - # TODO: This is the second attempt (after role.users) at using a - # new grid outside of the context of a primary master grid. The - # API here is really much hairier than I'd like... Looks like we - # shouldn't need a key for this one, for instance (no settings - # required), but there is plenty of room for improvement here. - employees = sorted(department.employees, key=unicode) - employees = AlchemyGrid('departments.employees', self.request, data=employees, model_class=model.Employee, - main_actions=[ - GridAction('view', icon='zoomin', - url=lambda r, i: self.request.route_url('employees.view', uuid=r.uuid)), - ]) - employees.configure(include=[employees.display_name], readonly=True) - kwargs['employees'] = employees + employees = [] + for employee in department_employees: + person = employee.person + employees.append({ + 'uuid': employee.uuid, + 'first_name': person.first_name, + 'last_name': person.last_name, + '_action_url_view': self.request.route_url('employees.view', uuid=employee.uuid), + '_action_url_edit': self.request.route_url('employees.edit', uuid=employee.uuid), + }) + kwargs['employees_data'] = employees - else: - kwargs['employees'] = None 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)) -class DepartmentVersionView(VersionView): - """ - View which shows version history for a department. - """ - parent_class = model.Department - route_model_view = 'departments.view' + 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)\ + .join(model.Vendor)\ + .filter(model.Vendor.uuid == self.request.params['uuid'])\ + .distinct()\ + .order_by(model.Department.name) + + def normalize(dept): + return { + 'uuid': dept.uuid, + 'number': dept.number, + 'name': dept.name, + } + + return self.json_response([normalize(d) for d in data]) + + @classmethod + def defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # list by vendor + config.add_route('{}.by_vendor'.format(route_prefix), '{}/by-vendor'.format(url_prefix)) + config.add_view(cls, attr='list_by_vendor', route_name='{}.by_vendor'.format(route_prefix), + permission='{}.list'.format(permission_prefix)) + + cls._defaults(config) -class DepartmentsByVendorGrid(AlchemyGridView): +def defaults(config, **kwargs): + base = globals() - mapped_class = model.Department - config_prefix = 'departments.by_vendor' - checkboxes = True - partial_only = True - - def query(self): - return self.make_query()\ - .outerjoin(model.Product)\ - .join(model.ProductCost)\ - .join(model.Vendor)\ - .filter(model.Vendor.uuid == self.request.params['uuid'])\ - .distinct()\ - .order_by(model.Department.name) - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.name, - ], - readonly=True) - return g - - -class DepartmentsAutocomplete(AutocompleteView): - - 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') - - # departments by vendor list - config.add_route('departments.by_vendor', '/departments/by-vendor') - config.add_view(DepartmentsByVendorGrid,route_name='departments.by_vendor', - permission='departments.list') - - DepartmentsView.defaults(config) - version_defaults(config, DepartmentVersionView, 'department') + defaults(config) diff --git a/tailbone/views/depositlinks.py b/tailbone/views/depositlinks.py index 0d08239c..1c9abde1 100644 --- a/tailbone/views/depositlinks.py +++ b/tailbone/views/depositlinks.py @@ -1,23 +1,23 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ @@ -31,33 +31,45 @@ from rattail.db import model from tailbone.views import MasterView -class DepositLinksView(MasterView): +class DepositLinkView(MasterView): """ Master view for deposit links. """ model_class = model.DepositLink url_prefix = '/deposit-links' + has_versions = True + + grid_columns = [ + 'code', + 'description', + 'amount', + ] + + form_fields = [ + 'code', + 'description', + 'amount', + ] def configure_grid(self, g): + super(DepositLinkView, self).configure_grid(g) g.filters['description'].default_active = True g.filters['description'].default_verb = 'contains' - g.default_sortkey = 'code' - g.configure( - include=[ - g.code, - g.description, - g.amount, - ], - readonly=True) + 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 f5dbe7d2..98bd4295 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -1,191 +1,465 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Email Views """ -from __future__ import unicode_literals, absolute_import +import logging +import re +import warnings -import formalchemy -from formalchemy.helpers import text_area -from pyramid.httpexceptions import HTTPFound +from wuttjamaican.util import parse_list -from rattail import mail -from rattail.db import api -from rattail.config import parse_list +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 MasterView, View -from tailbone.newgrids import Grid, GridColumn +from tailbone.views import View, MasterView -class EmailListGridColumn(GridColumn): - - def render(self, value): - if not value: - return '' - recips = parse_list(value) - if len(recips) < 3: - return value - return "{}, ...".format(', '.join(recips[:2])) +log = logging.getLogger(__name__) -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) - - -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' - - grid_factory = Grid + 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): + super().configure_grid(g) + + g.sort_on_backend = False + g.sort_multiple = False + g.set_sort_defaults('key') + + g.set_type('enabled', 'boolean') + g.set_link('key') + g.set_link('subject') + + g.set_searchable('key') + g.set_searchable('subject') + + # to + g.set_renderer('to', self.render_to_short) + + # 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 + + value = email['to'] + if not value: + return "" + recips = parse_list(value) + if len(recips) < 3: + return value + return "{}, ...".format(', '.join(recips[:2])) + def normalize(self, email): def get_recips(type_): 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), - 'subject': email.get_subject(data), - '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(), } - - def configure_grid(self, g): - g.columns = [ - GridColumn('key'), - GridColumn('prefix'), - GridColumn('subject'), - EmailListGridColumn('to'), - ] - - g.sorters['key'] = g.make_sorter('key', foldcase=True) - g.sorters['prefix'] = g.make_sorter('prefix', foldcase=True) - g.sorters['subject'] = g.make_sorter('subject', foldcase=True) - g.sorters['to'] = g.make_sorter('to', foldcase=True) - g.default_sortkey = 'key' - - # Make edit link visible by default, no "More" actions. - g.main_actions.append(g.more_actions.pop()) + 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() + return email['_email'].get_complete_subject(render=False) - 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 editable_instance(self, profile): + if self.rattail_config.demo(): + return profile['key'] != 'user_feedback' + return True - 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_index_url() if self.creating else self.get_action_url('view', email)) - form.readonly = self.viewing - return form + def deletable_instance(self, profile): + if self.rattail_config.demo(): + return profile['key'] != 'user_feedback' + return True - def save_form(self, form): - fs = form.fieldset - fs.sync() - key = fs.key._value + def configure_form(self, f): + super().configure_form(f) + profile = f.model_instance['_email'] - 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()) + # key + f.set_readonly('key') + + # fallback_key + f.set_readonly('fallback_key') + + # 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') @@ -198,40 +472,141 @@ 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 - - mail.deliver_message(self.rattail_config, msg) - - self.request.session.flash("Preview for '{0}' was emailed to {1}".format(key, recipient)) + try: + self.email_handler.send_message(email, context, + subject_prefix="[PREVIEW] ", + to=[recipient], + cc=None, bcc=None) + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + else: + self.request.session.flash( + "Preview for '{}' was emailed to {}".format( + key, recipient)) def preview_template(self, key, type_): - email = 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 + def defaults(cls, config): + # email preview + config.add_route('email.preview', '/email/preview/') + config.add_view(cls, route_name='email.preview', + renderer='/email/preview.mako', + permission='emailprofiles.preview') + config.add_tailbone_permission('emailprofiles', 'emailprofiles.preview', + "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) - - config.add_route('email.preview', '/email/preview/') - config.add_view(EmailPreview, route_name='email.preview', - renderer='/email/preview.mako', - permission='admin') + defaults(config) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index 69bef032..debd8fcb 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -1,213 +1,400 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Employee Views """ -from __future__ import unicode_literals, absolute_import - import sqlalchemy as sa -from rattail import enum from rattail.db import model -import formalchemy +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 MasterView, AutocompleteView -from tailbone.newgrids import AlchemyGrid, GridAction -from tailbone.newgrids.filters import EnumValueRenderer +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', + 'first_name', + 'last_name', + 'phone', + 'email', + 'status', + 'username', + ] + + form_fields = [ + 'person', + 'first_name', + 'last_name', + 'display_name', + 'phone', + 'email', + 'status', + 'full_time', + 'full_time_start', + 'id', + 'users', + 'stores', + 'departments', + ] + + def should_expose_quickie_search(self): + if self.expose_quickie_search: + return True + app = self.get_rattail_app() + return app.get_people_handler().should_expose_quickie_search() + + def get_quickie_perm(self): + return 'people.quickie' + + def get_quickie_url(self): + return self.request.route_url('people.quickie') + + def get_quickie_placeholder(self): + app = self.get_rattail_app() + return app.get_people_handler().get_quickie_search_placeholder() def configure_grid(self, g): + super().configure_grid(g) + route_prefix = self.get_route_prefix() - g.joiners['phone'] = lambda q: q.outerjoin(model.EmployeePhoneNumber, sa.and_( + # 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'): - g.filters['id'].label = "ID" + # 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 = enum.EMPLOYEE_STATUS_CURRENT - g.filters['status'].set_value_renderer(EnumValueRenderer(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.set_label('email', "Email Address") - 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)()) + if (self.request.has_perm('people.view_profile') + and self.should_link_straight_to_profile()): - 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)()) + # 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.default_sortkey = 'first_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() - g.append(forms.AssociationProxyField('first_name')) - g.append(forms.AssociationProxyField('last_name')) + 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) - g.configure( - include=[ - g.id.label("ID"), - g.first_name, - g.last_name, - g.phone.label("Phone Number"), - g.email.label("Email Address"), - g.status.with_renderer(forms.EnumFieldRenderer(enum.EMPLOYEE_STATUS)), - ], - readonly=True) + return url - if not self.request.has_perm('employees.edit'): - del g.id - del g.status + 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 == 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 configure_fieldset(self, fs): - fs.append(forms.AssociationProxyField('first_name')) - fs.append(forms.AssociationProxyField('last_name')) - fs.append(forms.AssociationProxyField('display_name')) - fs.append(StoresField('stores')) - fs.append(DepartmentsField('departments')) - fs.configure( - include=[ - fs.id.label("ID"), - fs.first_name, - fs.last_name, - fs.display_name, - fs.phone.label("Phone Number").readonly(), - fs.email.label("Email Address").readonly(), - fs.status.with_renderer(forms.EnumFieldRenderer(enum.EMPLOYEE_STATUS)), - fs.stores, - fs.departments, - ]) + 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.request.is_root: + return True + return not self.is_employee_protected(employee) + + def deletable_instance(self, employee): + if self.request.is_root: + return True + return not self.is_employee_protected(employee) + + def configure_form(self, f): + super().configure_form(f) + employee = f.model_instance + + 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") + + if not self.viewing: + 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 [ + (model.Person, 'uuid', 'person_uuid'), + (model.EmployeePhoneNumber, 'parent_uuid'), + (model.EmployeeEmailAddress, 'parent_uuid'), + (model.EmployeeStore, 'employee_uuid'), + (model.EmployeeDepartment, 'employee_uuid'), + ] + + def configure_get_simple_settings(self): + return [ + + # General + {'section': 'rattail', + 'option': 'employees.straight_to_profile', + 'type': bool}, + ] + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._employee_defaults(config) + + @classmethod + def _employee_defaults(cls, config): + permission_prefix = cls.get_permission_prefix() + model_title = cls.get_model_title() + model_title_plural = cls.get_model_title_plural() + + # view *all* employees + config.add_tailbone_permission(permission_prefix, + '{}.view_all'.format(permission_prefix), + "View *all* (not just current) {}".format(model_title_plural)) + + # view employee "secrets" + config.add_tailbone_permission(permission_prefix, + '{}.view_secrets'.format(permission_prefix), + "View \"secrets\" for {} (e.g. login ID, passcode)".format(model_title)) -class StoresField(formalchemy.Field): +def defaults(config, **kwargs): + base = globals() - def __init__(self, name, **kwargs): - kwargs.setdefault('type', formalchemy.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) - formalchemy.Field.__init__(self, name=name, **kwargs) - - def get_value(self, employee): - return [s.uuid for s in employee.stores] - - 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) - - -class DepartmentsField(formalchemy.Field): - - def __init__(self, name, **kwargs): - kwargs.setdefault('type', formalchemy.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) - formalchemy.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 == 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 new file mode 100644 index 00000000..44df359f --- /dev/null +++ b/tailbone/views/exports.py @@ -0,0 +1,190 @@ +# -*- 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/>. +# +################################################################################ +""" +Master class for generic export history views +""" + +import os +import shutil + +from pyramid.response import FileResponse +from webhelpers2.html import tags + +from tailbone.views import MasterView + + +class ExportMasterView(MasterView): + """ + Master class for generic export history views + """ + creatable = False + editable = False + downloadable = False + delete_export_files = False + + labels = { + 'id': "ID", + 'created_by': "Created by", + } + + grid_columns = [ + 'id', + 'created', + 'created_by', + 'record_count', + ] + + form_fields = [ + 'id', + 'created', + 'created_by', + 'record_count', + ] + + def get_export_key(self): + if hasattr(self, 'export_key'): + return self.export_key + + cls = self.get_model_class() + return cls.export_key + + def get_file_path(self, export, makedirs=False): + 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().configure_grid(g) + model = self.model + + # id + g.set_renderer('id', self.render_id) + g.set_link('id') + + # 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 configure_form(self, f): + super().configure_form(f) + export = f.model_instance + + # 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'] + return self.request.route_url('{}.download'.format(self.get_route_prefix()), uuid=uuid) + + def download(self): + """ + View for downloading the export file. + """ + export = self.get_instance() + path = self.get_file_path(export) + response = FileResponse(path, request=self.request) + 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's files as well as the export itself. + """ + # delete files for the export, if applicable + if self.delete_export_files: + path = self.get_file_path(export) + dirname = os.path.dirname(path) + if os.path.exists(dirname): + shutil.rmtree(dirname) + + # continue w/ normal deletion + super().delete_instance(export) diff --git a/tailbone/views/families.py b/tailbone/views/families.py index 92a6103c..2d445b78 100644 --- a/tailbone/views/families.py +++ b/tailbone/views/families.py @@ -1,23 +1,23 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ @@ -26,39 +26,94 @@ Family Views from __future__ import unicode_literals, absolute_import -from tailbone.views import MasterView - from rattail.db import model +from tailbone.views import MasterView -class FamiliesView(MasterView): + +class FamilyView(MasterView): """ Master view for the Family class. """ model_class = model.Family model_title_plural = "Families" route_prefix = 'families' + has_versions = True + results_downloadable = True grid_key = 'families' + grid_columns = [ + 'code', + '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' - g.configure( - include=[ - g.code, - g.name, - ], - readonly=True) - 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/grids/__init__.py b/tailbone/views/grids/__init__.py deleted file mode 100644 index ba3106ed..00000000 --- a/tailbone/views/grids/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ - -""" -Grid Views -""" - -from tailbone.views.grids.core import GridView -from tailbone.views.grids.alchemy import ( - AlchemyGridView, SortableAlchemyGridView, - PagedAlchemyGridView, SearchableAlchemyGridView) diff --git a/tailbone/views/grids/alchemy.py b/tailbone/views/grids/alchemy.py deleted file mode 100644 index ee564b8d..00000000 --- a/tailbone/views/grids/alchemy.py +++ /dev/null @@ -1,192 +0,0 @@ -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ - -""" -FormAlchemy Grid Views -""" - -from webhelpers import paginate - -from .core import GridView -from ... import grids -from ...db import Session - - -__all__ = ['AlchemyGridView', 'SortableAlchemyGridView', - 'PagedAlchemyGridView', 'SearchableAlchemyGridView'] - - -class AlchemyGridView(GridView): - - def make_query(self, session=Session): - query = session.query(self.mapped_class) - return self.modify_query(query) - - def modify_query(self, query): - return query - - def query(self): - return self.make_query() - - def make_grid(self, **kwargs): - self.update_grid_kwargs(kwargs) - return grids.AlchemyGrid( - self.request, self.mapped_class, self._data, **kwargs) - - def grid(self): - return self.make_grid() - - def __call__(self): - self._data = self.query() - grid = self.grid() - return self.render_grid(grid) - - -class SortableAlchemyGridView(AlchemyGridView): - - sort = None - full = True - - @property - def config_prefix(self): - raise NotImplementedError - - def join_map(self): - return {} - - def make_sort_map(self, *args, **kwargs): - return grids.util.get_sort_map( - self.mapped_class, names=args or None, **kwargs) - - def sorter(self, field): - return grids.util.sorter(field) - - def sort_map(self): - return self.make_sort_map() - - def make_sort_config(self, **kwargs): - return grids.util.get_sort_config( - self.config_prefix, self.request, **kwargs) - - def sort_config(self): - return self.make_sort_config(sort=self.sort) - - def modify_query(self, query): - return grids.util.sort_query( - query, self._sort_config, self.sort_map(), self.join_map()) - - def make_grid(self, **kwargs): - self.update_grid_kwargs(kwargs) - return grids.AlchemyGrid( - self.request, self.mapped_class, self._data, - sort_map=self.sort_map(), config=self._sort_config, **kwargs) - - def grid(self): - return self.make_grid() - - def __call__(self): - self._sort_config = self.sort_config() - self._data = self.query() - grid = self.grid() - return self.render_grid(grid) - - -class PagedAlchemyGridView(SortableAlchemyGridView): - - def make_pager(self): - config = self._sort_config - query = self.query() - return paginate.Page( - query, item_count=query.count(), - items_per_page=int(config['per_page']), - page=int(config['page']), - url=paginate.PageURL_WebOb(self.request)) - - def __call__(self): - self._sort_config = self.sort_config() - self._data = self.make_pager() - grid = self.grid() - grid.pager = self._data - return self.render_grid(grid) - - -class SearchableAlchemyGridView(PagedAlchemyGridView): - - def filter_exact(self, field): - return grids.search.filter_exact(field) - - def filter_ilike(self, field): - return grids.search.filter_ilike(field) - - def filter_int(self, field): - return grids.search.filter_int(field) - - def filter_soundex(self, field): - return grids.search.filter_soundex(field) - - def filter_ilike_and_soundex(self, field): - return grids.search.filter_ilike_and_soundex(field) - - def filter_gpc(self, field): - return grids.search.filter_gpc(field) - - def make_filter_map(self, **kwargs): - return grids.search.get_filter_map(self.mapped_class, **kwargs) - - def filter_map(self): - return self.make_filter_map() - - def make_filter_config(self, **kwargs): - return grids.search.get_filter_config( - self.config_prefix, self.request, self.filter_map(), **kwargs) - - def filter_config(self): - return self.make_filter_config() - - def make_search_form(self): - return grids.search.get_search_form( - self.request, self.filter_map(), self._filter_config) - - def search_form(self): - return self.make_search_form() - - def modify_query(self, query): - join_map = self.join_map() - if not hasattr(self, '_filter_config'): - self._filter_config = self.filter_config() - query = grids.search.filter_query( - query, self._filter_config, self.filter_map(), join_map) - if hasattr(self, '_sort_config'): - self._sort_config['joins'] = self._filter_config['joins'] - query = grids.util.sort_query( - query, self._sort_config, self.sort_map(), join_map) - return query - - def __call__(self): - self._filter_config = self.filter_config() - search = self.search_form() - self._sort_config = self.sort_config() - self._data = self.make_pager() - grid = self.grid() - grid.pager = self._data - return self.render_grid(grid, search) diff --git a/tailbone/views/grids/core.py b/tailbone/views/grids/core.py deleted file mode 100644 index a2cfd48d..00000000 --- a/tailbone/views/grids/core.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ - -""" -Core Grid View -""" - -from .. import View -from ... import grids - - -__all__ = ['GridView'] - - -class GridView(View): - - route_name = None - route_url = None - renderer = None - permission = None - - full = False - checkboxes = False - deletable = False - - partial_only = False - - def update_grid_kwargs(self, kwargs): - kwargs.setdefault('full', self.full) - kwargs.setdefault('checkboxes', self.checkboxes) - kwargs.setdefault('deletable', self.deletable) - kwargs.setdefault('partial_only', self.partial_only) - - def make_grid(self, **kwargs): - self.update_grid_kwargs(kwargs) - return grids.Grid(self.request, **kwargs) - - def grid(self): - return self.make_grid() - - def render_kwargs(self): - return {} - - def render_grid(self, grid, search=None, **kwargs): - kwargs = self.render_kwargs() - kwargs['search_form'] = search - return grids.util.render_grid(grid, **kwargs) - - def __call__(self): - grid = self.grid() - return self.render_grid(grid) diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index 4f1d7042..34211c30 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -1,180 +1,37 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 +(DEPRECATED) Views for handheld batches """ -from __future__ import unicode_literals, absolute_import +import warnings -from rattail.db.batch.handheld.handler import HandheldBatchHandler -from rattail.util import OrderedDict - -import formalchemy as fa -import formencode as fe -from webhelpers.html import tags - -from tailbone import forms -from tailbone.views.batch import FileBatchMasterView - -from dtail import enum -from dtail.db import model - - -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 - model_title_plural = "Handheld Batches" - batch_handler_class = HandheldBatchHandler - route_prefix = 'batch.handheld' - url_prefix = '/batch/handheld' - execution_options_schema = ExecutionOptions - editable = False - refreshable = False - - model_row_class = model.HandheldBatchRow - rows_creatable = False - rows_editable = True - - def configure_grid(self, g): - g.configure( - include=[ - g.id, - g.created, - g.created_by, - g.device_name, - g.executed, - g.executed_by, - ], - readonly=True) - - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - fs.filename, - fs.device_type.with_renderer(forms.renderers.EnumFieldRenderer(enum.HANDHELD_DEVICE_TYPE)), - fs.device_name, - fs.executed, - fs.executed_by, - ]) - if self.creating: - del fs.id - elif self.viewing and fs.model.inventory_batch: - fs.append(fa.Field('inventory_batch', value=fs.model.inventory_batch, renderer=InventoryBatchFieldRenderer)) - - def configure_row_grid(self, g): - g.configure( - include=[ - g.sequence, - g.upc.label("UPC"), - g.brand_name.label("Brand"), - g.description, - g.size, - g.cases, - g.units, - g.status_code.label("Status"), - ], - readonly=True) - - def row_grid_row_attrs(self, row, i): - attrs = {} - if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: - attrs['class_'] = 'warning' - return attrs - - 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('batch.rows', uuid=result.uuid) - return super(HandheldBatchView, self).get_execute_success_url(batch) - - @classmethod - def defaults(cls, config): - - # fix permission group title - config.add_tailbone_permission_group('batch.handheld', "Handheld Batches") - - cls._filebatch_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) +# 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 ee37db14..4622fa9f 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -1,23 +1,23 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ @@ -26,130 +26,56 @@ Views for inventory batches from __future__ import unicode_literals, absolute_import -from rattail import enum from rattail.db import model -from rattail.db.batch.inventory.handler import InventoryBatchHandler -import formalchemy as fa -from webhelpers.html import tags +import colander -from tailbone import forms -from tailbone.views.batch import BatchMasterView +from tailbone.views import MasterView -class HandheldBatchFieldRenderer(fa.FieldRenderer): +class InventoryAdjustmentReasonView(MasterView): """ - Renderer for inventory batch's "handheld batch" field. + Master view for inventory adjustment reasons. """ + model_class = model.InventoryAdjustmentReason + route_prefix = 'invadjust_reasons' + url_prefix = '/inventory-adjustment-reasons' - 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 InventoryBatchView(BatchMasterView): - """ - Master view for inventory batches. - """ - model_class = model.InventoryBatch - model_title_plural = "Inventory Batches" - batch_handler_class = InventoryBatchHandler - route_prefix = 'batch.inventory' - url_prefix = '/batch/inventory' - creatable = False - editable = False - refreshable = False - - model_row_class = model.InventoryBatchRow - rows_editable = True - - def _preconfigure_grid(self, g): - super(InventoryBatchView, self)._preconfigure_grid(g) - g.mode.set(renderer=forms.renderers.EnumFieldRenderer(enum.INVENTORY_MODE), - label="Count Mode") + grid_columns = [ + 'code', + 'description', + 'hidden', + ] def configure_grid(self, g): - super(InventoryBatchView, self).configure_grid(g) - g.append(g.mode) + super(InventoryAdjustmentReasonView, self).configure_grid(g) + g.set_sort_defaults('code') - def _preconfigure_fieldset(self, fs): - super(InventoryBatchView, self)._preconfigure_fieldset(fs) - fs.handheld_batch.set(renderer=HandheldBatchFieldRenderer, readonly=True) - fs.mode.set(renderer=forms.renderers.EnumFieldRenderer(enum.INVENTORY_MODE), - label="Count Mode") + def configure_form(self, f): + super(InventoryAdjustmentReasonView, self).configure_form(f) - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id, - fs.created, - fs.created_by, - fs.handheld_batch, - fs.mode, - fs.executed, - fs.executed_by, - ]) + # code + f.set_validator('code', self.unique_code) - def _preconfigure_row_grid(self, g): - super(InventoryBatchView, self)._preconfigure_row_grid(g) - g.upc.set(label="UPC") - g.brand_name.set(label="Brand") - g.status_code.set(label="Status") + 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 configure_row_grid(self, g): - g.configure( - include=[ - g.sequence, - g.upc, - g.brand_name, - g.description, - g.size, - g.cases, - g.units, - g.status_code, - ], - readonly=True) +# TODO: deprecate / remove this +InventoryAdjustmentReasonsView = InventoryAdjustmentReasonView - def row_grid_row_attrs(self, row, i): - attrs = {} - if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: - attrs['class_'] = 'warning' - return attrs - 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.brand_name.set(readonly=True) - fs.description.set(readonly=True) - fs.size.set(readonly=True) +def defaults(config, **kwargs): + base = globals() - 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, - ]) - - @classmethod - def defaults(cls, config): - - # fix permission group title - config.add_tailbone_permission_group('batch.inventory', "Inventory Batches") - - cls._batch_defaults(config) - cls._defaults(config) + InventoryAdjustmentReasonView = kwargs.get('InventoryAdjustmentReasonView', base['InventoryAdjustmentReasonView']) + InventoryAdjustmentReasonView.defaults(config) def includeme(config): - InventoryBatchView.defaults(config) + defaults(config) diff --git a/tailbone/views/labels/__init__.py b/tailbone/views/labels/__init__.py index fc0a312e..923b5cae 100644 --- a/tailbone/views/labels/__init__.py +++ b/tailbone/views/labels/__init__.py @@ -2,22 +2,22 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# Copyright © 2010-2017 Lance Edgar # # This file is part of Rattail. # # Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ diff --git a/tailbone/views/labels/batch.py b/tailbone/views/labels/batch.py new file mode 100644 index 00000000..e9d2971b --- /dev/null +++ b/tailbone/views/labels/batch.py @@ -0,0 +1,38 @@ +# -*- 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/>. +# +################################################################################ +""" +(Deprecated!) Views for label batches + +Please use `tailbone.views.batch.labels` instead. +""" + +import warnings + + +def includeme(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 c0b5f61b..fa878448 100644 --- a/tailbone/views/labels/profiles.py +++ b/tailbone/views/labels/profiles.py @@ -1,132 +1,191 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ 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 MasterView -from tailbone.views.continuum import VersionView, version_defaults -class ProfilesView(MasterView): +class LabelProfileView(MasterView): """ Master view for the LabelProfile model. """ model_class = model.LabelProfile model_title = "Label Profile" url_prefix = '/labels/profiles' + has_versions = True + + grid_columns = [ + 'ordinal', + '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): - g.default_sortkey = 'ordinal' - g.configure( - include=[ - g.ordinal, - g.code, - g.description, - g.visible, - ], - readonly=True) + 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() -class LabelProfileVersionView(VersionView): - """ - View which shows version history for a label profile. - """ - parent_class = model.LabelProfile - model_title = "Label Profile" - route_model_list = 'label_profiles' - route_model_view = 'labelprofiles.view' + 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) + + 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 + + form.insert_before(schema.children[0].name, 'label_profile') + form.set_readonly('label_profile') + form.set_renderer('label_profile', lambda p, f: p.description) + + form.insert_after('label_profile', 'printer_spec') + form.set_readonly('printer_spec') + form.set_renderer('printer_spec', lambda p, f: p.printer_spec) + + 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 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')) +def defaults(config, **kwargs): + base = globals() - read_profile = HTTPFound(location=request.route_url( - 'labelprofiles.view', uuid=profile.uuid)) - - 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 - - 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 - - return {'profile': profile, 'printer': printer} + LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView']) + LabelProfileView.defaults(config) def includeme(config): - ProfilesView.defaults(config) - version_defaults(config, LabelProfileVersionView, 'labelprofile', template_prefix='/labels/profiles') - - # 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 640da516..21a5e58f 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1,45 +1,71 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Model Master View """ -from __future__ import unicode_literals, absolute_import - -import re +import io +import os +import csv +import datetime +import getpass +import shutil +import logging +from collections import OrderedDict +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 edbob.util import prettify +from wuttjamaican.util import get_class_hierarchy +from rattail.db.continuum import model_transaction_query +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 +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 webhelpers2.html import HTML, tags +from webob.compat import cgi_FieldStorage -from tailbone import forms, newgrids as grids +from tailbone import forms, grids, diffs from tailbone.views import View -from tailbone.newgrids import filters, AlchemyGrid, GridAction +from tailbone.db import Session +from tailbone.config import global_help_url + + +log = logging.getLogger(__name__) + + +class EverythingComplete(Exception): + pass class MasterView(View): @@ -50,16 +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 + + # 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 = {} @@ -67,24 +156,161 @@ class MasterView(View): grid_index = None use_index_links = False + has_versions = False + default_help_url = None + help_url = None + + labels = {'uuid': "UUID"} + + customer_key_fields = {} + member_key_fields = {} + product_key_fields = {} + # ROW-RELATED ATTRS FOLLOW: has_rows = False model_row_class = None + rows_title = None + rows_pageable = True + rows_sortable = True + rows_filterable = True rows_viewable = True rows_creatable = False rows_editable = False + rows_editable_but_not_directly = False rows_deletable = False + rows_deletable_speedbump = True + rows_bulk_deletable = False + rows_default_pagesize = None + rows_downloadable_csv = False + rows_downloadable_xlsx = 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 ############################## @@ -94,52 +320,856 @@ class MasterView(View): 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 renderered grid only. Otherwise + 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 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 create(self): + 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) + + 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) + + self.before_render_index() + return self.render_to_response('index', context) + + def before_render_index(self): + """ + 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_grid(self, factory=None, key=None, data=None, columns=None, session=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=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 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): + """ + Quickie search - tries to do a simple lookup based on a key + value. If a record is found, user is redirected to its view. + """ + entry = self.request.params.get('entry', '').strip() + if not entry: + self.request.session.flash("No search criteria specified", 'error') + return self.redirect(self.request.get_referrer()) + + obj = self.do_quickie_lookup(entry) + if not obj: + model_title = self.get_model_title() + self.request.session.flash(f"{model_title} not found: {entry}", 'error') + return self.redirect(self.request.get_referrer()) + + return self.redirect(self.get_quickie_result_url(obj)) + + def do_quickie_lookup(self, entry): + pass + + def get_quickie_result_url(self, obj): + return self.get_action_url('view', obj) + + def make_row_grid(self, factory=None, key=None, data=None, columns=None, + session=None, **kwargs): + """ + Make and return a new (configured) rows grid instance. + """ + 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 = 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 + + def get_row_grid_columns(self): + if hasattr(self, 'row_grid_columns'): + return self.row_grid_columns + + def make_row_grid_kwargs(self, **kwargs): + """ + Return a dict of kwargs to be used when constructing a new rows grid. + """ + 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), + } + + if self.rows_default_pagesize: + defaults['pagesize'] = self.rows_default_pagesize + + if self.has_rows and 'actions' not in defaults: + actions = [] + + # 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 form.validate(): - self.save_create_form(form) - instance = form.fieldset.model - self.after_create(instance) - self.request.session.flash("{} has been created: {}".format( - self.get_model_title(), self.get_instance_title(instance))) - return self.redirect_after_create(instance) - return self.render_to_response('create', {'form': form}) + if self.validate_form(form): + # let save_create_form() return alternate object if necessary + 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(template, context) + + def make_create_form(self): + return self.make_form() def save_create_form(self, form): + uploads = self.normalize_uploads(form) self.before_create(form) - form.save() + 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 redirect_after_create(self, instance): + 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): + """ + Simple renderer which truncates the (string) value to 100 chars. + """ + 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 redirect_after_create(self, instance, **kwargs): + if self.populatable and self.should_populate(instance): + 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 + + def populate(self): + """ + View for populating a new object. What exactly this means / does will + depend on the logic in :meth:`populate_object()`. + """ + obj = self.get_instance() + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + # showing progress requires a separate thread; start that first + key = '{}.populate'.format(route_prefix) + progress = self.make_progress(key) + thread = Thread(target=self.populate_thread, args=(obj.uuid, progress)) # TODO: uuid? + thread.start() + + # Send user to progress page. + kwargs = { + 'cancel_url': self.get_action_url('view', obj), + 'cancel_msg': "{} population was canceled.".format(self.get_model_title()), + } + + return self.render_progress(progress, kwargs) + + def populate_thread(self, uuid, progress): # TODO: uuid? + """ + Thread target for populating new object with progress indicator. + """ + # mustn't use tailbone web session here + 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: + session.rollback() + msg = "{} population failed".format(self.get_model_title()) + log.warning("{}: {}".format(msg, obj), exc_info=True) + 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.refresh(obj) + session.close() + + # finalize progress + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = self.get_action_url('view', obj) + progress.session.save() + + def populate_object(self, session, obj, progress=None): + """ + You must define this if new objects require population. + """ + raise NotImplementedError + def view(self, instance=None): """ View for viewing details of an existing model record. @@ -157,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, @@ -172,46 +1206,492 @@ 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()) + 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 make_row_grid_tools(self): + 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) + 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), + 'instance_url': self.get_action_url('view', instance), + 'form': form, + }) + + def configure_clone_form(self, form): pass - def make_row_grid(self, **kwargs): + def clone_instance(self, instance): """ - Make and return a new (configured) rows grid instance. + 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. """ - parent = kwargs.pop('instance', self.get_instance()) - data = self.get_row_data(parent) - kwargs['instance'] = parent - kwargs = self.make_row_grid_kwargs(**kwargs) - key = '{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()]) - factory = self.get_grid_factory() - grid = factory(key, self.request, data=data, model_class=self.model_row_class, **kwargs) - self._preconfigure_row_grid(grid) - self.configure_row_grid(grid) - grid.load_settings() + 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): + """ + View to list version history for an object. + """ + instance = self.get_instance() + instance_title = self.get_instance_title(instance) + grid = self.make_version_grid(instance=instance) + + # return grid only, if partial page was requested + if self.request.params.get('partial'): + # render grid data only, as JSON + return self.json_response(grid.get_table_data()) + + return self.render_to_response('versions', { + 'instance': instance, + 'instance_title': instance_title, + 'instance_url': self.get_action_url('view', instance), + 'grid': grid, + }) + + @classmethod + def get_version_grid_key(cls): + """ + Returns the unique key to be used for the version grid, for caching + sort/filter options etc. + """ + if hasattr(cls, 'version_grid_key'): + return cls.version_grid_key + return '{}.history'.format(cls.get_route_prefix()) + + def get_version_data(self, instance, order_by=True): + """ + Generate the base data set for the version grid. + """ + model_class = self.get_model_class() + transaction_class = continuum.transaction_class(model_class) + query = model_transaction_query(self.Session(), instance, model_class, + child_classes=self.normalize_version_child_classes()) + 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. + """ + classes = [] + for supp in self.iter_view_supplements(): + classes.extend(supp.get_version_child_classes()) + return classes + + def normalize_version_child_classes(self): + classes = [] + for cls in self.get_version_child_classes(): + if not isinstance(cls, tuple): + cls = (cls, 'uuid', 'uuid') + elif len(cls) == 2: + cls = tuple([cls[0], cls[1], 'uuid']) + 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()) + transaction_id = self.request.matchdict['txnid'] + transaction = transactions.filter(Transaction.id == transaction_id).first() + if not transaction: + return self.notfound() + older = transactions.filter(Transaction.issued_at <= transaction.issued_at)\ + .filter(Transaction.id != transaction_id)\ + .order_by(Transaction.issued_at.desc())\ + .first() + newer = transactions.filter(Transaction.issued_at >= transaction.issued_at)\ + .filter(Transaction.id != transaction_id)\ + .order_by(Transaction.issued_at)\ + .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': 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() + + def fields_for_version(self, version): + mapper = orm.class_mapper(version.__class__) + fields = sorted(mapper.columns.keys()) + fields.remove('transaction_id') + fields.remove('end_transaction_id') + fields.remove('operation_type') + return fields + + def get_relevant_versions(self, transaction, instance): + versions = [] + version_cls = self.get_model_version_class() + query = self.Session.query(version_cls)\ + .filter(version_cls.transaction == transaction)\ + .filter(version_cls.uuid == instance.uuid) + versions.extend(query.all()) + for cls, foreign_attr, primary_attr in self.normalize_version_child_classes(): + version_cls = continuum.version_class(cls) + query = self.Session.query(version_cls)\ + .filter(version_cls.transaction == transaction)\ + .filter(getattr(version_cls, foreign_attr) == getattr(instance, primary_attr)) + versions.extend(query.all()) + return versions + + def render_version_old_field_value(self, version, field): + return repr(getattr(version.previous, field)) + + def render_version_new_field_value(self, version, field, typ): + return repr(getattr(version, field)) + + def configure_common_form(self, form): + """ + 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() + """ + 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 get_quick_row_form_factory(self, **kwargs): + return forms.Form + + def get_quick_row_form_fields(self, **kwargs): + pass + + def make_quick_row_form_schema(self, **kwargs): + schema = colander.MappingSchema() + schema.add(colander.SchemaNode(colander.String(), name='quick_entry')) + return schema + + 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(), + } + 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]) + + def make_row_grid_tools(self, obj): + return self.make_default_row_grid_tools(obj) + + # TODO: depracate / remove this def get_effective_row_query(self): """ Convenience method which returns the "effective" query for the master grid, filtered and sorted to match what would show on the UI, but not paged etc. """ - parent = self.get_instance() - grid = self.make_row_grid(instance=parent, sortable=False, pageable=False, - main_actions=[]) - return grid._fa_grid.rows - - def _preconfigure_row_grid(self, g): - pass - - def configure_row_grid(self, grid): - grid.configure() + return self.get_effective_row_data(sort=False) def get_row_data(self, instance): """ @@ -219,13 +1699,18 @@ class MasterView(View): """ raise NotImplementedError - @classmethod - def get_row_route_prefix(cls): + def get_effective_row_data(self, session=None, sort=False, **kwargs): """ - Route prefix specific to the row-level views for a batch, e.g. - ``'vendorcatalogs.rows'``. + Convenience method which returns the "effective" data for the row grid, + filtered (and optionally sorted) to match what would show on the UI, + but not paged. """ - return "{}.rows".format(cls.get_route_prefix()) + if session is None: + session = self.Session() + kwargs.setdefault('paginated', False) + kwargs.setdefault('sortable', sort) + grid = self.make_row_grid(session=session, **kwargs) + return grid.get_visible_data() @classmethod def get_row_url_prefix(cls): @@ -243,61 +1728,38 @@ class MasterView(View): """ return "{}.rows".format(cls.get_permission_prefix()) - def make_row_grid_kwargs(self, **kwargs): + def row_editable(self, row): """ - Return a dict of kwargs to be used when constructing a new rows grid. + Returns boolean indicating whether or not the given row can be + considered "editable". Returns ``True`` by default; override as + necessary. """ - route_prefix = self.get_row_route_prefix() - permission_prefix = self.get_row_permission_prefix() + return True - defaults = { - 'width': 'full', - 'filterable': True, - 'sortable': True, - 'pageable': True, - 'row_attrs': self.row_grid_row_attrs, - 'model_title': self.get_row_model_title(), - 'model_title_plural': self.get_row_model_title_plural(), - 'permission_prefix': permission_prefix, - 'route_prefix': route_prefix, - } - - 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: - actions.append(grids.GridAction('delete', icon='trash', url=self.row_delete_action_url)) - - defaults['main_actions'] = actions - - defaults.update(kwargs) - return defaults + def row_view_action_url(self, row, i): + return self.get_row_action_url('view', row) def row_edit_action_url(self, row, i): - return self.get_row_action_url('edit', row) + if self.row_editable(row): + return self.get_row_action_url('edit', row) def row_delete_action_url(self, row, i): - return self.get_row_action_url('delete', row) + if self.row_deletable(row): + return self.get_row_action_url('delete', row) def row_grid_row_attrs(self, row, i): return {} @classmethod def get_row_model_title(cls): + if hasattr(cls, 'row_model_title'): + return cls.row_model_title return "{} Row".format(cls.get_model_title()) @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): @@ -306,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()) @@ -322,32 +1784,108 @@ class MasterView(View): self.grid_count = len(data) return self.view(instance) + def download(self): + """ + View for downloading a data file. + """ + obj = self.get_instance() + filename = self.request.GET.get('filename', None) + path = self.download_path(obj, filename) + 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 = content_type + return response + + def download_content_type(self, path, filename): + """ + 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. """ self.editing = True instance = self.get_instance() + instance_title = self.get_instance_title(instance) + + if not self.editable_instance(instance): + self.request.session.flash("Edit is not permitted for {}: {}".format( + self.get_model_title(), instance_title), 'error') + return self.redirect(self.get_action_url('view', instance)) + form = self.make_form(instance) if self.request.method == 'POST': - if form.validate(): + if self.validate_form(form): self.save_edit_form(form) + # note we must fetch new instance title, in case it changed self.request.session.flash("{} has been updated: {}".format( self.get_model_title(), self.get_instance_title(instance))) return self.redirect_after_edit(instance) - return self.render_to_response('edit', { + context = { 'instance': instance, - 'instance_title': self.get_instance_title(instance), + 'instance_title': instance_title, 'instance_deletable': self.deletable_instance(instance), - 'form': form}) + 'form': form, + } + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() + return self.render_to_response('edit', context) 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): @@ -355,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() @@ -363,10 +1901,11 @@ class MasterView(View): if not self.deletable_instance(instance): self.request.session.flash("Deletion is not permitted for {}: {}".format( - self.get_model_title(), instance_title)) + self.get_model_title(), instance_title), 'error') 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': @@ -376,17 +1915,497 @@ 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 + """ + 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): + if self.deletable_instance(obj): + self.delete_instance(obj) + if i % 1000 == 0: + session.flush() + + self.progress_loop(delete, objects, progress, + message="Deleting objects") + + def get_bulk_delete_session(self): + return self.make_isolated_session() + + def bulk_delete_thread(self, objects, progress): + """ + Thread target for bulk-deleting current results, with progress. + """ + session = self.get_bulk_delete_session() + objects = objects.with_session(session).all() + try: + self.bulk_delete_objects(session, objects, progress=progress) + + # If anything goes wrong, rollback and log the error etc. + except Exception as error: + session.rollback() + log.exception("execution failed for batch results") + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Bulk deletion failed: {}".format( + simple_error(error)) + progress.session.save() + + # If no error, check result flag (false means user canceled). + else: + session.commit() + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + 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() + + # 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 + + # make our progress tracker + progress = self.make_execute_progress(obj, ws=ws) + + # 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() + + # we're done here if using websockets + if ws: + return self.json_response({'ok': True}) + + # 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. + """ + 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: + success_msg = self.execute_instance(obj, user, + progress=progress, + **kwargs) + + # If anything goes wrong, rollback and log the error etc. + except Exception as error: + session.rollback() + log.exception("{} failed to execute: {}".format(self.get_model_title(), obj)) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = self.execute_error_message(error) + progress.session.save() + + # If no error, check result flag (false means user canceled). + else: + session.commit() + 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(), + 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': + 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 = 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: + 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, + '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): + """ + If applicable, your view should override this in order to confirm that + 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): + 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] 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)): + result[field] = sorted(set(remove[field] + keep[field])) + else: + result[field] = remove[field] + keep[field] + return result + + def merge_objects(self, removing, keeping): + """ + Merge the two given objects. You should probably override this; + default behavior is merely to delete the 'removing' object. + """ + 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 ############################## @@ -400,6 +2419,13 @@ class MasterView(View): raise NotImplementedError("You must define the `model_class` for: {}".format(cls)) return getattr(cls, 'model_class', None) + @classmethod + def get_model_version_class(cls): + """ + Returns the version class for the master model class. + """ + return continuum.version_class(cls.get_model_class()) + @classmethod def get_normalized_model_name(cls): """ @@ -413,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): @@ -429,9 +2485,14 @@ class MasterView(View): """ if hasattr(cls, 'model_title'): return cls.model_title - title = cls.get_model_class().__name__ - # convert "CamelCase" to "Camel Case" - return re.sub(r'([a-z])([A-Z])', r'\g<1> \g<2>', 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): @@ -439,7 +2500,12 @@ class MasterView(View): Return a "humanized" (and plural) version of the model name, for display in templates. """ - return getattr(cls, 'model_title_plural', '{0}s'.format(cls.get_model_title())) + if hasattr(cls, 'model_title_plural'): + return cls.model_title_plural + try: + return cls.get_model_class().get_model_title_plural() + except (NotImplementedError, AttributeError): + return '{}s'.format(cls.get_model_title()) @classmethod def get_route_prefix(cls): @@ -448,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): @@ -476,12 +2544,16 @@ class MasterView(View): """ return getattr(cls, 'permission_prefix', cls.get_route_prefix()) - def get_index_url(self): + def get_index_url(self, **kwargs): """ Returns the master view's index URL. """ - return self.request.route_url(self.get_route_prefix()) + 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): """ @@ -489,14 +2561,147 @@ class MasterView(View): """ return getattr(cls, 'index_title', cls.get_model_title_plural()) - def get_action_url(self, action, instance): + @classmethod + def get_config_title(cls): """ - Generate a URL for the given action on the given instance. + Returns the view's "config title". """ - return self.request.route_url('{0}.{1}'.format(self.get_route_prefix(), action), - **self.get_action_route_kwargs(instance)) + if hasattr(cls, 'config_title'): + return cls.config_title - def render_to_response(self, template, data): + 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() + return self.request.route_url('{}.{}'.format(route_prefix, action), **kw) + + 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'). @@ -512,15 +2717,32 @@ class MasterView(View): 'permission_prefix': self.get_permission_prefix(), 'index_title': self.get_index_title(), 'index_url': self.get_index_url(), + 'config_title': self.get_config_title(), 'action_url': self.get_action_url, 'grid_index': self.grid_index, + 'help_url': self.get_help_url(), + 'help_markdown': self.get_help_markdown(), + 'can_edit_help': self.can_edit_help(), + 'quickie': None, } + 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() @@ -528,13 +2750,20 @@ class MasterView(View): context.update(data) context.update(self.template_kwargs(**context)) - if hasattr(self, 'template_kwargs_{}'.format(template)): - context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context)) + + method_name = f'template_kwargs_{template}' + if hasattr(self, method_name): + context.update(getattr(self, method_name)(**context)) + for supp in self.iter_view_supplements(): + if hasattr(supp, 'template_kwargs'): + context.update(getattr(supp, 'template_kwargs')(**context)) + if hasattr(supp, method_name): + context.update(getattr(supp, method_name)(**context)) # First try the template path most specific to the view. + mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template) try: - return render_to_response('{}/{}.mako'.format(self.get_template_prefix(), template), - context, request=self.request) + return render_to_response(mako_path, context, request=self.request) except IOError: @@ -586,65 +2815,447 @@ class MasterView(View): return render('{}/{}.mako'.format(self.get_template_prefix(), template), context, request=self.request) - def get_fallback_templates(self, 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 ############################## - @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', AlchemyGrid) - @classmethod def get_grid_key(cls): """ Returns the unique key to be used for the grid, for caching sort/filter options etc. """ - return getattr(cls, 'grid_key', '{0}s'.format(cls.get_normalized_model_name())) + if hasattr(cls, 'grid_key'): + return cls.grid_key + # default previously came from cls.get_normalized_model_name() but this is hopefully better + return cls.get_route_prefix() - def make_grid_kwargs(self, **kwargs): - """ - Return a dictionary of kwargs to be passed to the factory when creating - new grid instances. - """ - defaults = { - 'width': 'full', - 'filterable': self.filterable, - 'sortable': True, - 'default_sortkey': getattr(self, 'default_sortkey', None), - 'sortdir': getattr(self, 'sortdir', 'asc'), - 'pageable': self.pageable, - 'checkboxes': self.checkboxes, - 'checked': self.checked, - 'row_attrs': self.get_row_attrs, - 'cell_attrs': self.get_cell_attrs, - 'model_title': self.get_model_title(), - 'model_title_plural': self.get_model_title_plural(), - 'permission_prefix': self.get_permission_prefix(), - 'route_prefix': self.get_route_prefix(), - } - 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 get_row_grid_key(self): + 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): - return self.get_main_actions(), self.get_more_actions() + """ """ + 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, [] + if len(main + more) <= 3: + main, more = main + more, [] + return main, more def get_row_attrs(self, row, i): """ @@ -654,8 +3265,12 @@ class MasterView(View): defined; it depends on the type of data the grid deals with. """ if callable(self.row_attrs): - return self.row_attrs(row, i) - return self.row_attrs + attrs = self.row_attrs(row, i) + else: + attrs = dict(self.row_attrs) + if self.mergeable: + attrs['data-uuid'] = row.uuid + return attrs def get_cell_attrs(self, row, column): """ @@ -671,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) @@ -686,71 +3306,129 @@ 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')) - 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 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(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 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 make_grid(self, **kwargs): - """ - Make and return a new (configured) grid instance. - """ - factory = self.get_grid_factory() - key = self.get_grid_key() - data = self.get_data(session=kwargs.get('session')) - kwargs = self.make_grid_kwargs(**kwargs) - grid = factory(key, self.request, data=data, model_class=self.get_model_class(error=False), **kwargs) - self._preconfigure_grid(grid) - self.configure_grid(grid) - grid.load_settings() - return grid - - def _preconfigure_grid(self, grid): - pass - - def configure_grid(self, grid): - """ - Configure the grid, customizing as necessary. Subclasses are - encouraged to override this method. - - As a bare minimum, the logic for this method must at some point invoke - the ``configure()`` method on the grid instance. The default - implementation does exactly (and only) this, passing no arguments. - This requirement is a result of using FormAlchemy under the hood, and - it is in fact a call to :meth:`formalchemy:formalchemy.tables.Grid.configure()`. - """ - if hasattr(grid, 'configure'): - grid.configure() - def get_data(self, session=None): """ Generate the base data set for the grid. This typically will be a @@ -773,23 +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) - def get_effective_data(self, session=None): - """ - 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() - grid = self.make_grid(session=session, pageable=False, - main_actions=[], more_actions=[]) - return grid._fa_grid.rows + # 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) - def get_effective_query(self, session): - return self.get_effective_data(session) + 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 @@ -806,6 +3484,924 @@ 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. + """ + results = self.get_effective_data() + + # 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 + with open(path, 'rb') as f: + response.body = f.read() + os.remove(path) + + response.content_length = len(response.body) + 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 = io.StringIO() + writer = UnicodeDictWriter(data, fields) + writer.writeheader() + for row in self.get_effective_row_data(sort=True): + writer.writerow(self.get_row_csv_row(row, fields)) + response = self.request.response + filename = self.get_row_results_csv_filename(obj) + response.text = data.getvalue() + response.content_type = 'text/csv' + response.content_disposition = 'attachment; filename={}'.format(filename) + data.close() + response.content_length = len(response.body) + return response + + def get_row_results_csv_filename(self, instance): + return '{}.csv'.format(self.get_row_grid_key()) + + def get_csv_fields(self): + """ + 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) + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + fields.append(prop.key) + return fields + + def get_row_csv_fields(self): + """ + Return the list of row fields to be written to CSV download. + """ + try: + mapper = orm.class_mapper(self.model_row_class) + except: + fields = self.get_row_form_fields() + if not fields: + fields = self.get_row_grid_columns() + else: + fields = [] + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + fields.append(prop.key) + return fields + + def get_csv_row(self, obj, fields): + """ + Return a dict for use when writing the row's data to CSV download. + """ + app = self.get_rattail_app() + csvrow = {} + for field in fields: + value = getattr(obj, field, None) + if isinstance(value, datetime.datetime): + # TODO: this assumes value is *always* naive UTC + value = app.localtime(value, from_utc=True) + csvrow[field] = '' if value is None else str(value) + return csvrow + + def get_row_csv_row(self, row, fields): + """ + Return a dict for use when writing the row's data to CSV download. + """ + app = self.get_rattail_app() + csvrow = {} + for field in fields: + value = getattr(row, field, None) + if isinstance(value, datetime.datetime): + # TODO: this assumes value is *always* naive UTC + value = app.localtime(value, from_utc=True) + csvrow[field] = '' if value is None else str(value) + return csvrow + ############################## # CRUD Stuff ############################## @@ -815,69 +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 unicode(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) - 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 = formalchemy.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 @@ -889,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 @@ -919,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): """ @@ -935,10 +4760,92 @@ 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 ############################## + def create_row(self): + """ + View for creating a new row record. + """ + self.creating = True + parent = self.get_instance() + 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 self.validate_row_form(form): + self.before_create_row(form) + 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', { + 'index_url': index_url, + 'index_title': '{} {}'.format( + self.get_model_title(), + self.get_instance_title(parent)), + 'form': form}) + + # TODO: still need to verify this logic + def save_create_row_form(self, 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 + + def after_create_row(self, row_object): + pass + + def redirect_after_create_row(self, row, **kwargs): + return self.redirect(self.get_row_action_url('view', row)) + + 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): """ View for viewing details of a single data row. @@ -949,57 +4856,32 @@ class MasterView(View): parent = self.get_parent(row) return self.render_to_response('view_row', { 'instance': row, - 'instance_title': self.get_row_instance_title(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(), - 'index_url': self.get_action_url('view', parent), - 'index_title': '{} {}'.format( - self.get_model_title(), - self.get_instance_title(parent)), 'action_url': self.get_row_action_url, 'form': form}) - def edit_row(self): + def rows_creatable_for(self, instance): """ - View for editing an existing model record. + Returns boolean indicating whether or not the given instance should + allow new rows to be added to it. """ - self.editing = True - row = self.get_row_instance() - form = self.make_row_form(row) + return True - if self.request.method == 'POST': - if form.validate(): - self.save_edit_row_form(form) - return self.redirect_after_edit_row(row) - - parent = self.get_parent(row) - return self.render_to_response('edit_row', { - 'instance': row, - 'instance_title': self.get_row_instance_title(row), - 'instance_deletable': self.row_deletable(row), - 'index_url': self.get_action_url('view', parent), - 'index_title': '{} {}'.format( - self.get_model_title(), - self.get_instance_title(parent)), - 'form': form}) - - def save_edit_row_form(self, form): - self.save_row_form(form) - self.after_edit_row(form.fieldset.model) - - def save_row_form(self, form): - form.save() - - def after_edit_row(self, row): + def rows_quickable_for(self, instance): """ - Event hook, called just after an existing row object is saved. + Must return boolean indicating whether the "quick row" feature should + be allowed for the given instance. Returns ``True`` by default. """ - - def redirect_after_edit_row(self, row): - return self.redirect(self.get_action_url('view', self.get_parent(row))) + return True def row_editable(self, row): """ @@ -1009,23 +4891,103 @@ class MasterView(View): """ return True + def edit_row(self): + """ + View for editing an existing model record. + """ + 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 self.validate_row_form(form): + self.save_edit_row_form(form) + return self.redirect_after_edit_row(row) + + parent = self.get_parent(row) + 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, + 'dform': form.make_deform_form(), + }) + + def save_edit_row_form(self, form): + 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 after_edit_row(self, row): + """ + Event hook, called just after an existing row object is saved. + """ + + def redirect_after_edit_row(self, row, **kwargs): + return self.redirect(self.get_row_action_url('view', 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. """ + 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 @@ -1034,58 +4996,595 @@ 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 self.creating: - kwargs.setdefault('cancel_url', self.get_action_url('view', self.get_parent(instance))) - else: - kwargs.setdefault('cancel_url', self.get_row_action_url('view', instance)) - - 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): + 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. """ - return self.request.route_url('{}.{}'.format(self.get_row_route_prefix(), action), - **self.get_row_action_route_kwargs(row)) + 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): """ 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 @@ -1095,86 +5594,618 @@ 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): """ Provide default configuration for a master 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 - config.add_route(route_prefix, '{0}/'.format(url_prefix)) - config.add_view(cls, attr='index', route_name=route_prefix, - permission='{0}.list'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{0}.list'.format(permission_prefix), - "List/Search {0}".format(model_title_plural)) + 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), + **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: - config.add_route('{0}.create'.format(route_prefix), '{0}/new'.format(url_prefix)) - config.add_view(cls, attr='create', route_name='{0}.create'.format(route_prefix), - permission='{0}.create'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{0}.create'.format(permission_prefix), - "Create new {0}".format(model_title)) + config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix), + "Create new {}".format(model_title)) + 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)) + + # populate new object + if cls.populatable: + config.add_route('{}.populate'.format(route_prefix), '{}/{{uuid}}/populate'.format(url_prefix)) + 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), + 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), + "Bulk delete {}".format(model_title_plural)) + + # merge + if cls.mergeable: + config.add_route('{}.merge'.format(route_prefix), '{}/merge'.format(url_prefix)) + config.add_view(cls, attr='merge', route_name='{}.merge'.format(route_prefix), + permission='{}.merge'.format(permission_prefix)) + 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".format(model_title)) + 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)) + # 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(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(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), + "Download associated data for {}".format(model_title)) + # 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(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 + if cls.has_rows and cls.rows_downloadable_csv: + config.add_tailbone_permission(permission_prefix, '{}.row_results_csv'.format(permission_prefix), + "Download {} results as CSV".format(row_model_title)) + config.add_route('{}.row_results_csv'.format(route_prefix), '{}/{{uuid}}/rows-csv'.format(url_prefix)) + 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: + config.add_tailbone_permission(permission_prefix, '{}.create_row'.format(permission_prefix), + "Create new {} rows".format(model_title)) + 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)) + + # 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 and 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.has_rows: + if cls.rows_viewable: + 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 and 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'.format(permission_prefix)) + if cls.has_rows: + if cls.rows_editable or cls.rows_editable_but_not_directly: + config.add_tailbone_permission(permission_prefix, '{}.edit_row'.format(permission_prefix), + "Edit individual {}".format(row_model_title_plural)) + if cls.rows_editable: + config.add_route('{}.edit_row'.format(route_prefix), + '{}/rows/{{row_uuid}}/edit'.format(instance_url_prefix)) + config.add_view(cls, attr='edit_row', route_name='{}.edit_row'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix)) # 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='{}.edit'.format(permission_prefix)) + 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/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 c27be2c9..9199c025 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -1,129 +1,42 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Message Views """ -from __future__ import unicode_literals, absolute_import - -import json -import pytz - -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 -from webhelpers.html import tags, HTML +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 MasterView +from tailbone.util import raw_datetime -class SubjectFieldRenderer(formalchemy.FieldRenderer): - - def render_readonly(self, **kwargs): - subject = self.raw_value - if not subject: - return '' - return tags.link_to(subject, self.request.route_url('messages.view', uuid=self.field.parent.model.uuid)) - - -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 TerseRecipientsFieldRenderer(formalchemy.FieldRenderer): - - def render_readonly(self, **kwargs): - recipients = self.raw_value - if not recipients: - return '' - message = self.field.parent.model - 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) and ( - message.sender is not self.request.user or not recips): - recips.insert(0, 'you') - if len(recips) < 5: - return ', '.join(recips) - return "{}, ...".format(', '.join(recips[:4])) - - -class MessagesView(MasterView): +class MessageView(MasterView): """ Base class for message views. """ @@ -133,6 +46,22 @@ class MessagesView(MasterView): checkboxes = True replying = False reply_header_sent_format = '%a %d %b %Y at %I:%M %p' + listable = False + + grid_columns = [ + 'subject', + 'sender', + 'recipients', + 'sent', + ] + + form_fields = [ + 'sender', + 'recipients', + 'sent', + 'subject', + 'body', + ] def get_index_title(self): if self.listing: @@ -140,27 +69,27 @@ 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" return "Message Inbox" - def get_index_url(self): + def get_index_url(self, **kwargs): # not really used, but necessary to make certain other code happy return self.request.route_url('messages.inbox') 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): @@ -177,75 +106,150 @@ class MessagesView(MasterView): .filter(model.MessageRecipient.recipient == self.request.user) def configure_grid(self, g): + super().configure_grid(g) + model = self.model - 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) + # 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.configure( - include=[ - g.subject.with_renderer(SubjectFieldRenderer), - g.sender.with_renderer(SenderFieldRenderer).label("From"), - g.recipients.with_renderer(TerseRecipientsFieldRenderer).label("To"), - g.sent, - ], - readonly=True) + g.set_renderer('sent', self.render_sent) + g.set_renderer('sender', self.render_sender) + g.set_renderer('recipients', self.render_recipients) - def row_attrs(self, row, i): - recip = self.get_recipient(row) - if recip: - return {'data-uuid': recip.uuid} - return {} + g.set_link('subject') - def make_form(self, instance, **kwargs): - form = super(MessagesView, self).make_form(instance, **kwargs) - if self.creating: - form.cancel_url = self.request.get_referrer(default=self.request.route_url('messages.inbox')) - return form + g.set_label('sender', "From") + g.set_label('recipients', "To") + + def render_sent(self, message, column_name): + return raw_datetime(self.rattail_config, message.sent) + + def render_sender(self, message, field): + sender = message.sender + if sender is self.request.user: + return 'you' + 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 + if recipients: + 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) and ( + message.sender is not self.request.user or not recips): + recips.insert(0, "you") + if len(recips) < 5: + return ", ".join(recips) + return "{}, ...".format(', '.join(recips[:4])) + return "" + + 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. @@ -255,44 +259,48 @@ 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( + self.get_instance_title(obj))) def filter_reply_recipient(self, user): 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) @@ -318,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 @@ -339,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): """ @@ -364,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'): @@ -372,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( @@ -386,12 +415,14 @@ 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 = Session.query(model.MessageRecipient).get(uuid) if uuid else None - if recip and recip.recipient is self.request.user: - if recip.status != new_status: - recip.status = new_status + recip = self.Session.query(model.MessageRecipient)\ + .filter(model.MessageRecipient.message_uuid == uuid)\ + .filter(model.MessageRecipient.recipient_uuid == self.request.user.uuid)\ + .first() + if recip and recip.status != new_status: + recip.status = new_status route = 'messages.{}'.format('archive' if dest == 'inbox' else 'inbox') return self.redirect(self.request.route_url(route)) @@ -421,8 +452,11 @@ class MessagesView(MasterView): cls._defaults(config) +# TODO: deprecate / remove this +MessagesView = MessageView -class InboxView(MessagesView): + +class InboxView(MessageView): """ Inbox message view. """ @@ -430,15 +464,15 @@ class InboxView(MessagesView): grid_key = 'messages.inbox' index_title = "Message Inbox" - def get_index_url(self): + def get_index_url(self, **kwargs): 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. """ @@ -446,15 +480,15 @@ class ArchiveView(MessagesView): grid_key = 'messages.archive' index_title = "Message Archive" - def get_index_url(self): + def get_index_url(self, **kwargs): 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. """ @@ -463,7 +497,7 @@ class SentView(MessagesView): checkboxes = False index_title = "Sent Messages" - def get_index_url(self): + def get_index_url(self, **kwargs): return self.request.route_url('messages.sent') def query(self, session): @@ -471,27 +505,69 @@ 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)\ + .join(model.Person) + g.filters['recipients'] = g.make_filter('recipients', model.Person.display_name, + 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 18957915..405b1ca3 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1,60 +1,154 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 """ -from __future__ import unicode_literals, absolute_import +import datetime +import logging +from collections import OrderedDict import sqlalchemy as sa +from sqlalchemy import orm +import sqlalchemy_continuum as continuum -from pyramid.httpexceptions import HTTPFound, HTTPNotFound +from rattail.db import api +from rattail.db.model import Person, PersonNote, MergePeopleRequest +from rattail.util import simple_error -from tailbone.views import MasterView, AutocompleteView +import colander +from webhelpers2.html import HTML, tags -from rattail.db import model +from tailbone import forms, grids +from tailbone.db import TrainwreckSession +from tailbone.views import MasterView +from tailbone.util import raw_datetime -class PeopleView(MasterView): +log = logging.getLogger(__name__) + + +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', + 'first_name', + '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): - 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)) + super().configure_grid(g) + route_prefix = self.get_route_prefix() + model = self.model - g.filters['email'] = g.make_filter('email', model.PersonEmailAddress.address, - label="Email Address") - g.filters['phone'] = g.make_filter('phone', model.PersonPhoneNumber.number, - label="Phone Number") + # 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) + + # 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) + + 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' @@ -62,69 +156,2042 @@ 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.configure( - include=[ - g.display_name.label("Full Name"), - g.first_name, - g.last_name, - g.phone.label("Phone Number"), - g.email.label("Email Address"), - ], - readonly=True) + g.set_sort_defaults('display_name') + + g.set_label('display_name', "Full Name") + 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 configure_fieldset(self, fs): - fs.configure( - include=[ - fs.display_name.label("Full Name"), - fs.first_name, - fs.middle_name, - fs.last_name, - fs.phone.label("Phone Number").readonly(), - fs.email.label("Email Address").readonly(), - fs.address.label("Mailing Address").readonly(), - ]) + 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.request.is_root: + return True + return not self.is_person_protected(person) + + def deletable_instance(self, person): + if self.request.is_root: + return True + return not self.is_person_protected(person) + + def configure_form(self, f): + super().configure_form(f) + + # preferred_first_name + if self.people_handler.should_use_preferred_first_name(): + f.insert_after('first_name', 'preferred_first_name') + + def objectify(self, form, data=None): + if data is None: + data = form.validated + + # do normal create/update + person = super().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'), + (model.PersonMailingAddress, 'parent_uuid'), + (model.Employee, 'person_uuid'), + (model.CustomerPerson, 'person_uuid'), + (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.get(model.Person, uuid) + if not person: + return self.notfound() + if person.users: + raise RuntimeError("person {} already has {} user accounts: ".format( + person.uuid, len(person.users), person)) + user = model.User() + user.username = api.make_username(person) + user.person = person + user.active = False + self.Session.add(user) + self.Session.flush() + 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'}, -class PeopleAutocomplete(AutocompleteView): + # Profile View + {'section': 'tailbone', + 'option': 'people.profile.expose_members', + 'type': bool}, + {'section': 'tailbone', + 'option': 'people.profile.expose_transactions', + 'type': bool}, + ] - mapped_class = model.Person - fieldname = 'display_name' + @classmethod + def defaults(cls, config): + cls._people_defaults(config) + cls._defaults(config) + + @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), + request_method='POST') + config.add_view(cls, attr='make_user', route_name='{}.make_user'.format(route_prefix), + permission='users.create') + + # 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): - PeopleView.defaults(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') + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.people') + else: + defaults(config) diff --git a/tailbone/views/permissions.py b/tailbone/views/permissions.py new file mode 100644 index 00000000..5168d544 --- /dev/null +++ b/tailbone/views/permissions.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Raw Permission Views +""" + +from __future__ import unicode_literals, absolute_import + +from sqlalchemy import orm + +from rattail.db import model + +from tailbone.views import MasterView + + +class PermissionView(MasterView): + """ + Master view for the permissions model. + """ + model_class = model.Permission + model_title = "Raw Permission" + editable = False + bulk_deletable = True + + grid_columns = [ + 'role', + 'permission', + ] + + def query(self, session): + model = self.model + query = super(PermissionView, self).query(session) + query = query.options(orm.joinedload(model.Permission.role)) + return query + + +def defaults(config, **kwargs): + base = globals() + + PermissionView = kwargs.get('PermissionView', base['PermissionView']) + PermissionView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/poser/__init__.py b/tailbone/views/poser/__init__.py new file mode 100644 index 00000000..b81580d4 --- /dev/null +++ b/tailbone/views/poser/__init__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Poser Views +""" + +from __future__ import unicode_literals, absolute_import + + +def includeme(config): + config.include('tailbone.views.poser.reports') + config.include('tailbone.views.poser.views') diff --git a/tailbone/views/poser/master.py b/tailbone/views/poser/master.py new file mode 100644 index 00000000..1f04fe61 --- /dev/null +++ b/tailbone/views/poser/master.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Poser Views for Views... +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.util import simple_error + +from webhelpers2.html import HTML, tags + +from tailbone.views import MasterView + + +class PoserMasterView(MasterView): + """ + Master view base class for Poser + """ + model_key = 'key' + filterable = False + pageable = False + + def __init__(self, request): + super(PoserMasterView, self).__init__(request) + app = self.get_rattail_app() + self.poser_handler = app.get_poser_handler() + + # nb. pre-load all data b/c all views potentially need access + self.data = self.get_data() + + def get_data(self, session=None): + if hasattr(self, 'data'): + return self.data + + try: + return self.get_poser_data(session) + + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + + if not self.request.is_root: + self.request.session.flash("You must become root in order " + "to do Poser Setup.", 'error') + else: + link = tags.link_to("Poser Setup", + self.request.route_url('poser_setup')) + msg = HTML.literal("Please see the {} page.".format(link)) + self.request.session.flash(msg, 'error') + return [] + + def get_poser_data(self, session=None): + raise NotImplementedError("TODO: you must implement this in subclass") diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py new file mode 100644 index 00000000..ded80b18 --- /dev/null +++ b/tailbone/views/poser/reports.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Poser Report Views +""" + +import os + +from rattail.util import simple_error + +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML + +from .master import PoserMasterView + + +class PoserReportView(PoserMasterView): + """ + Master view for Poser reports + """ + normalized_model_name = 'poser_report' + model_title = "Poser Report" + model_key = 'report_key' + route_prefix = 'poser_reports' + url_prefix = '/poser/reports' + editable = False # TODO: should allow this somehow? + downloadable = True + + labels = { + 'report_key': "Poser Key", + } + + grid_columns = [ + 'report_key', + 'report_name', + 'description', + 'error', + ] + + form_fields = [ + 'report_key', + 'report_name', + 'description', + 'flavor', + 'include_comments', + 'module_file', + 'module_file_path', + 'error', + ] + + has_rows = True + + @property + def model_row_class(self): + return self.model.ReportOutput + + row_labels = { + 'id': "ID", + } + + row_grid_columns = [ + 'id', + 'report_name', + 'filename', + 'created', + 'created_by', + ] + + def get_poser_data(self, session=None): + return self.poser_handler.get_all_reports(ignore_errors=False) + + def configure_grid(self, g): + super().configure_grid(g) + + g.sorters['report_key'] = g.make_simple_sorter('report_key', foldcase=True) + g.sorters['report_name'] = g.make_simple_sorter('report_name', foldcase=True) + + g.set_renderer('error', self.render_report_error) + + g.set_sort_defaults('report_name') + + g.set_link('report_key') + g.set_link('report_name') + g.set_link('description') + g.set_link('error') + + g.set_searchable('report_key') + g.set_searchable('report_name') + g.set_searchable('description') + + if self.request.has_perm('report_output.create'): + g.actions.append(self.make_action( + 'generate', icon='arrow-circle-right', + url=self.get_generate_url)) + + def get_generate_url(self, report, i=None): + if not report.get('error'): + return self.request.route_url('generate_specific_report', + type_key=report['report'].type_key) + + def render_report_error(self, report, field): + error = report.get('error') + if error: + return HTML.tag('span', class_='has-background-warning', c=[error]) + + def get_instance(self): + report_key = self.request.matchdict['report_key'] + for report in self.get_data(): + if report['report_key'] == report_key: + return report + raise self.notfound() + + def get_instance_title(self, report): + return report['report_name'] + + def make_form_schema(self): + return PoserReportSchema() + + def make_create_form(self): + return self.make_form({}) + + def save_create_form(self, form): + self.before_create(form) + + report = self.poser_handler.make_report( + form.validated['report_key'], + form.validated['report_name'], + form.validated['description'], + flavor=form.validated['flavor'], + include_comments=form.validated['include_comments']) + + return report + + def configure_form(self, f): + super().configure_form(f) + report = f.model_instance + + # report_key + f.set_default('report_key', 'cool_widgets') + f.set_helptext('report_key', "Unique computer-friendly key for the report type.") + if self.creating: + f.set_validator('report_key', self.unique_report_key) + + # report_name + f.set_default('report_name', "Cool Widgets Weekly") + f.set_helptext('report_name', "Human-friendly display name for the report.") + + # description + f.set_default('description', "How many cool widgets we come across each week") + f.set_helptext('description', "Brief description of the report.") + + # flavor + if self.creating: + f.set_helptext('flavor', "Determines the type of sample code to generate.") + flavors = self.poser_handler.get_supported_report_flavors() + values = [(key, flavor['description']) + for key, flavor in flavors.items()] + f.set_widget('flavor', dfwidget.SelectWidget(values=values)) + f.set_validator('flavor', colander.OneOf(flavors)) + if flavors: + f.set_default('flavor', list(flavors)[0]) + else: + f.remove('flavor') + + # include_comments + if not self.creating: + f.remove('include_comments') + + # module_file + if self.creating: + f.remove('module_file') + else: + # nb. set this key as workaround for render method, which + # expects object to have this field + report['module_file'] = os.path.basename(report['module_file_path']) + f.set_renderer('module_file', self.render_downloadable_file) + + # error + if self.creating or not report.get('error'): + f.remove('error') + else: + f.set_renderer('error', self.render_report_error) + + def unique_report_key(self, node, value): + for report in self.get_data(): + if report['report_key'] == value: + raise node.raise_invalid("Poser report key must be unique") + + def download_path(self, report, filename): + return report['module_file_path'] + + def get_row_data(self, report): + model = self.model + + if report.get('error'): + return [] + + return self.Session.query(model.ReportOutput)\ + .filter(model.ReportOutput.report_type == report['report'].type_key) + + def get_parent(self, output): + key = output.report_type + for report in self.get_data(): + if not report.get('error'): + if report['report'].type_key == key: + return report + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.set_renderer('id', self.render_id_str) + + g.set_sort_defaults('created', 'desc') + + g.set_link('id') + g.set_link('filename') + g.set_link('created') + + def row_view_action_url(self, output, i): + return self.request.route_url('report_output.view', uuid=output.uuid) + + def delete_instance(self, report): + self.poser_handler.delete_report(report['report_key']) + + def replace(self): + app = self.get_rattail_app() + report = self.get_instance() + + value = self.request.POST['replacement_module'] + tempdir = app.make_temp_dir() + filepath = os.path.join(tempdir, os.path.basename(value.filename)) + with open(filepath, 'wb') as f: + f.write(value.file.read()) + + try: + newreport = self.poser_handler.replace_report(report['report_key'], + filepath) + except Exception as error: + self.request.session.flash(simple_error(error), 'error') + else: + report = newreport + finally: + os.remove(filepath) + os.rmdir(tempdir) + + return self.redirect(self.get_action_url('view', report)) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._poser_report_defaults(config) + + @classmethod + def _poser_report_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + model_title = cls.get_model_title() + + # replace module + config.add_tailbone_permission(permission_prefix, + '{}.replace'.format(permission_prefix), + "Upload replacement module for {}".format(model_title)) + config.add_route('{}.replace'.format(route_prefix), + '{}/replace'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='replace', + route_name='{}.replace'.format(route_prefix), + permission='{}.replace'.format(permission_prefix)) + + +class PoserReportSchema(colander.MappingSchema): + + report_key = colander.SchemaNode(colander.String()) + + report_name = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + flavor = colander.SchemaNode(colander.String()) + + include_comments = colander.SchemaNode(colander.Bool()) + + +def defaults(config, **kwargs): + base = globals() + + PoserReportView = kwargs.get('PoserReportView', base['PoserReportView']) + PoserReportView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py new file mode 100644 index 00000000..27efd549 --- /dev/null +++ b/tailbone/views/poser/views.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Poser Views for Views... +""" + +import colander + +from .master import PoserMasterView +from tailbone.providers import get_all_providers + + +class PoserViewView(PoserMasterView): + """ + Master view for Poser views + """ + normalized_model_name = 'poser_view' + model_title = "Poser View" + route_prefix = 'poser_views' + url_prefix = '/poser/views' + configurable = True + config_title = "Included Views" + + # TODO + creatable = False + editable = False + deletable = False + # downloadable = True + + grid_columns = [ + 'key', + 'class_name', + 'description', + 'error', + ] + + def get_poser_data(self, session=None): + return self.poser_handler.get_all_tailbone_views() + + def make_form_schema(self): + return PoserViewSchema() + + def make_create_form(self): + return self.make_form({}) + + def configure_form(self, f): + super().configure_form(f) + view = f.model_instance + + # key + f.set_default('key', 'cool_widget') + f.set_helptext('key', "Unique key for the view; used as basis for filename.") + if self.creating: + f.set_validator('view_key', self.unique_view_key) + + # class_name + f.set_default('class_name', "CoolWidget") + f.set_helptext('class_name', "Python-friendly basis for view class name.") + + # description + f.set_default('description', "Master view for Cool Widgets") + f.set_helptext('description', "Brief description of the view.") + + def unique_view_key(self, node, value): + for view in self.get_data(): + if view['key'] == value: + raise node.raise_invalid("Poser view key must be unique") + + def collect_available_view_settings(self): + + # TODO: this probably should be more dynamic? definitely need + # to let integration packages register some more options... + + everything = {'rattail': { + + 'people': { + + # TODO: need some way for integration / extension + # packages to register alternate view options for some + # of these. that is the main reason these are dicts + # even though at the moment it's a bit overkill. + + 'tailbone.views.customers': { + # 'spec': 'tailbone.views.customers', + 'label': "Customers", + }, + 'tailbone.views.customergroups': { + # 'spec': 'tailbone.views.customergroups', + 'label': "Customer Groups", + }, + 'tailbone.views.employees': { + # 'spec': 'tailbone.views.employees', + 'label': "Employees", + }, + 'tailbone.views.members': { + # 'spec': 'tailbone.views.members', + 'label': "Members", + }, + }, + + 'products': { + + 'tailbone.views.departments': { + # 'spec': 'tailbone.views.departments', + 'label': "Departments", + }, + + 'tailbone.views.ifps': { + # 'spec': 'tailbone.views.ifps', + 'label': "IFPS PLU Codes", + }, + + 'tailbone.views.subdepartments': { + # 'spec': 'tailbone.views.subdepartments', + 'label': "Subdepartments", + }, + + 'tailbone.views.vendors': { + # 'spec': 'tailbone.views.vendors', + 'label': "Vendors", + }, + + 'tailbone.views.products': { + # 'spec': 'tailbone.views.products', + 'label': "Products", + }, + + 'tailbone.views.brands': { + # 'spec': 'tailbone.views.brands', + 'label': "Brands", + }, + + 'tailbone.views.categories': { + # 'spec': 'tailbone.views.categories', + 'label': "Categories", + }, + + 'tailbone.views.depositlinks': { + # 'spec': 'tailbone.views.depositlinks', + 'label': "Deposit Links", + }, + + 'tailbone.views.families': { + # 'spec': 'tailbone.views.families', + 'label': "Families", + }, + + 'tailbone.views.reportcodes': { + # 'spec': 'tailbone.views.reportcodes', + 'label': "Report Codes", + }, + }, + + 'batches': { + + 'tailbone.views.batch.delproduct': { + 'label': "Delete Product", + }, + 'tailbone.views.batch.inventory': { + 'label': "Inventory", + }, + 'tailbone.views.batch.labels': { + 'label': "Labels", + }, + 'tailbone.views.batch.newproduct': { + 'label': "New Product", + }, + 'tailbone.views.batch.pricing': { + 'label': "Pricing", + }, + 'tailbone.views.batch.product': { + 'label': "Product", + }, + 'tailbone.views.batch.vendorcatalog': { + 'label': "Vendor Catalog", + }, + }, + + 'other': { + + 'tailbone.views.datasync': { + # 'spec': 'tailbone.views.datasync', + 'label': "DataSync", + }, + + 'tailbone.views.importing': { + # 'spec': 'tailbone.views.importing', + 'label': "Importing / Exporting", + }, + + 'tailbone.views.stores': { + # 'spec': 'tailbone.views.stores', + 'label': "Stores", + }, + + 'tailbone.views.taxes': { + # 'spec': 'tailbone.views.taxes', + 'label': "Taxes", + }, + }, + }} + + for key, views in everything['rattail'].items(): + for vkey, view in views.items(): + view['options'] = [vkey] + + providers = get_all_providers(self.rattail_config) + for provider in providers.values(): + + # loop thru provider top-level groups + for topkey, groups in provider.get_provided_views().items(): + + # get or create top group + topgroup = everything.setdefault(topkey, {}) + + # loop thru provider view groups + for key, views in groups.items(): + + # add group to top group, if it's new + if key not in topgroup: + topgroup[key] = views + + # also must init the options for group + for vkey, view in views.items(): + view['options'] = [vkey] + + else: # otherwise must "update" existing group + + # get ref to existing ("standard") group + stdgroup = topgroup[key] + + # loop thru views within provider group + for vkey, view in views.items(): + + # add view to group if it's new + if vkey not in stdgroup: + view['options'] = [vkey] + stdgroup[vkey] = view + + else: # otherwise "update" existing view + stdgroup[vkey]['options'].append(view['spec']) + + return everything + + def configure_get_simple_settings(self): + settings = [] + + view_settings = self.collect_available_view_settings() + for topgroup in view_settings.values(): + for view_section, section_settings in topgroup.items(): + for key in section_settings: + settings.append({'section': 'tailbone.includes', + 'option': key}) + + return settings + + def configure_get_context(self, simple_settings=None, + input_file_templates=True): + + # first get normal context + context = super().configure_get_context( + simple_settings=simple_settings, + input_file_templates=input_file_templates) + + # first add available options + view_settings = self.collect_available_view_settings() + view_options = {} + for topgroup in view_settings.values(): + for key, views in topgroup.items(): + for vkey, view in views.items(): + view_options[vkey] = view['options'] + context['view_options'] = view_options + + # then add all available settings as sorted (key, label) options + for topkey, topgroup in view_settings.items(): + for key in list(topgroup): + settings = topgroup[key] + settings = [(key, setting.get('label', key)) + for key, setting in settings.items()] + settings.sort(key=lambda itm: itm[1]) + topgroup[key] = settings + context['view_settings'] = view_settings + + return context + + def configure_flash_settings_saved(self): + super().configure_flash_settings_saved() + self.request.session.flash("Please restart the web app!", 'warning') + + +class PoserViewSchema(colander.MappingSchema): + + key = colander.SchemaNode(colander.String()) + + class_name = colander.SchemaNode(colander.String()) + + description = colander.SchemaNode(colander.String()) + + # include_comments = colander.SchemaNode(colander.Bool()) + + +def defaults(config, **kwargs): + base = globals() + + PoserViewView = kwargs.get('PoserViewView', base['PoserViewView']) + PoserViewView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py new file mode 100644 index 00000000..3986f8b0 --- /dev/null +++ b/tailbone/views/principal.py @@ -0,0 +1,206 @@ +# -*- 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/>. +# +################################################################################ +""" +"Principal" master view +""" + +import copy +from collections import OrderedDict + +from rattail.core import Object + +from webhelpers2.html import HTML + +from tailbone.db import Session +from tailbone.views import MasterView + + +class PrincipalMasterView(MasterView): + """ + Master view base class for security principal models, i.e. User and Role. + """ + + def get_fallback_templates(self, template, **kwargs): + return [ + '/principal/{}.mako'.format(template), + ] + 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('wutta_permissions', {})) + + # sort groups, and permissions for each group, for UI's sake + sorted_perms = sorted(permissions.items(), key=self.perm_sortkey) + for key, group in sorted_perms: + group['perms'] = sorted(group['perms'].items(), key=self.perm_sortkey) + + # if both field values are in query string, do lookup + principals = None + 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 + + 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) + cls._defaults(config) + + @classmethod + def _principal_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() + + # find principal by permission + 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 e579d290..8461ae03 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1,54 +1,55 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Product Views """ -from __future__ import unicode_literals, absolute_import - -import os import re - +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, batches -from rattail.db import model, api, auth, Session as RattailSession +from rattail import enum, pod, sil +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 simple_error -import formalchemy as fa -from pyramid import httpexceptions -from pyramid.renderers import render_to_response -from webhelpers.html import tags +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags, HTML -from tailbone import forms, newgrids as grids -from tailbone.db import Session -from tailbone.views import MasterView, SearchableAlchemyGridView, AutocompleteView -from tailbone.views.continuum import VersionView, version_defaults -from tailbone.forms.renderers import products as products_forms -from tailbone.progress import SessionProgress +from tailbone import forms, grids +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 @@ -70,232 +71,1679 @@ from tailbone.progress import SessionProgress # return query -class DescriptionFieldRenderer(fa.TextFieldRenderer): - """ - Renderer for product descriptions within the grid; adds hyperlink. - """ - - def render_readonly(self, **kwargs): - description = self.raw_value - if description is None: - return '' - if kwargs.get('link'): - product = self.field.parent.model - description = tags.link_to(description, kwargs['link'](product)) - return description - - -class ProductsView(MasterView): +class ProductView(MasterView): """ Master view for the Product class. """ - model_class = model.Product + model_class = Product + has_versions = True + results_downloadable_xlsx = True + supports_autocomplete = True + bulk_deletable = True + mergeable = True + touchable = True + configurable = True - # child_version_classes = [ - # (model.ProductCode, 'product_uuid'), - # (model.ProductCost, 'product_uuid'), - # (model.ProductPrice, 'product_uuid'), - # ] + 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", + } - # 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) + grid_columns = [ + '_product_key_', + 'brand', + 'description', + 'size', + 'department', + 'vendor', + 'regular_price', + 'current_price', + ] + + 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): - self.request = 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) - if not auth.has_permission(session, user, '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)) - 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().configure_grid(g) + app = self.get_rattail_app() + model = self.model - def join_vendor(q): - return q.outerjoin(model.ProductCost, + ProductCostCode = orm.aliased(model.ProductCost) + ProductCostCodeAny = orm.aliased(model.ProductCost) + + def join_vendor_code(q): + return q.outerjoin(ProductCostCode, sa.and_( - model.ProductCost.product_uuid == model.Product.uuid, - model.ProductCost.preference == 1))\ - .outerjoin(model.Vendor) + ProductCostCode.product_uuid == model.Product.uuid, + ProductCostCode.preference == 1)) - 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) + def join_vendor_code_any(q): + return q.outerjoin(ProductCostCodeAny, + ProductCostCodeAny.product_uuid == model.Product.uuid) + + # 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['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) - g.joiners['regular_price'] = lambda q: q.outerjoin(model.ProductPrice, - model.ProductPrice.uuid == model.Product.regular_price_uuid) - g.joiners['current_price'] = lambda q: q.outerjoin(model.ProductPrice, - model.ProductPrice.uuid == model.Product.current_price_uuid) g.joiners['code'] = lambda q: q.outerjoin(model.ProductCode) - g.joiners['vendor'] = join_vendor - g.joiners['vendor_any'] = join_vendor_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) + # 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['upc'].label = "UPC" 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, label="Vendor (preferred)") - g.filters['vendor_any'] = g.make_filter('vendor_any', self.VendorAny.name, label="Vendor (any)") - # factory=VendorAnyFilter, joiner=join_vendor_any) - g.default_sortkey = 'upc' + # 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) - product_link = lambda p: self.get_action_url('view', p) + # 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)") - g.upc.set(renderer=forms.renderers.GPCFieldRenderer) - g.upc.attrs(link=product_link) + # 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) - g.description.set(renderer=DescriptionFieldRenderer) - g.description.attrs(link=product_link) + # family + g.set_joiner('family', lambda q: q.outerjoin(model.Family)) + g.set_filter('family', model.Family.name) - g.regular_price.set(renderer=forms.renderers.PriceFieldRenderer) - g.current_price.set(renderer=forms.renderers.PriceFieldRenderer) - g.configure( - include=[ - g.upc.label("UPC"), - g.brand, - g.description, - g.size, - g.subdepartment, - g.vendor.label("Pref. Vendor"), - g.regular_price.label("Reg. Price"), - g.current_price.label("Cur. Price"), - ], - readonly=True) + # regular_price + g.set_label('regular_price', "Reg. Price") + RegularPrice = orm.aliased(model.ProductPrice) + g.set_joiner('regular_price', lambda q: q.outerjoin( + 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") - # TODO: need to check for 'print labels' permission here also - if self.print_labels: - g.more_actions.append(grids.GridAction('print_label', icon='print')) + # 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( + CurrentPrice, CurrentPrice.uuid == model.Product.current_price_uuid)) + g.set_sorter('current_price', CurrentPrice.price) + g.set_filter('current_price', CurrentPrice.price, label="Current Price") + + # tpr_price + TPRPrice = orm.aliased(model.ProductPrice) + g.set_joiner('tpr_price', lambda q: q.outerjoin( + TPRPrice, TPRPrice.uuid == model.Product.tpr_price_uuid)) + g.set_filter('tpr_price', TPRPrice.price) + + # sale_price + SalePrice = orm.aliased(model.ProductPrice) + g.set_joiner('sale_price', lambda q: q.outerjoin( + SalePrice, SalePrice.uuid == model.Product.sale_price_uuid)) + g.set_filter('sale_price', SalePrice.price) + + # suggested_price + g.set_renderer('suggested_price', self.render_grid_suggested_price) + + # (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('on_hand', self.render_on_hand) + g.set_renderer('on_order', self.render_on_order) + + g.set_link('item_id') + g.set_link('description') + + 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 "" + app = self.get_rattail_app() + return app.render_quantity(inventory.on_hand) + + def render_on_order(self, product, column): + inventory = product.inventory + if not inventory: + return "" + app = self.get_rattail_app() + return app.render_quantity(inventory.on_order) def template_kwargs_index(self, **kwargs): - if self.print_labels: - kwargs['label_profiles'] = Session.query(model.LabelProfile)\ - .filter(model.LabelProfile.visible == True)\ - .order_by(model.LabelProfile.ordinal)\ - .all() + 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 row_attrs(self, row, i): - - attrs = {'uuid': row.uuid} - + def grid_extra_class(self, product, i): classes = [] - if row.not_for_sale: + if product.not_for_sale: classes.append('not-for-sale') - if row.deleted: + if product.discontinued: + classes.append('discontinued') + if product.deleted: classes.append('deleted') if classes: - attrs['class_'] = ' '.join(classes) + return ' '.join(classes) - return attrs + 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 configure_fieldset(self, fs): + def configure_form(self, f): + super().configure_form(f) + model = self.model + product = f.model_instance - fs.upc.set(renderer=forms.renderers.GPCFieldRenderer) - fs.brand.set(options=[]) - fs.unit_of_measure.set(renderer=forms.renderers.EnumFieldRenderer(enum.UNIT_OF_MEASURE)) - fs.regular_price.set(renderer=forms.renderers.PriceFieldRenderer) - fs.current_price.set(renderer=forms.renderers.PriceFieldRenderer) + # unit_size + f.set_type('unit_size', 'quantity') - fs.append(fa.Field('current_price_ends', type=fa.types.DateTime, - value=lambda p: p.current_price.ends if p.current_price else None)) + # 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)) - fs.configure( - include=[ - fs.upc.label("UPC"), - fs.brand.with_renderer(forms.renderers.BrandFieldRenderer), - fs.description, - fs.unit_size, - fs.unit_of_measure.label("Unit of Measure"), - fs.size, - fs.weighed, - fs.case_pack, - fs.department.with_renderer(products_forms.DepartmentFieldRenderer), - fs.subdepartment.with_renderer(products_forms.SubdepartmentFieldRenderer), - fs.category.with_renderer(products_forms.CategoryFieldRenderer), - fs.family, - fs.report_code, - fs.regular_price, - fs.current_price, - fs.current_price_ends, - fs.deposit_link, - fs.tax, - fs.organic, - fs.discountable, - fs.special_order, - fs.not_for_sale, - fs.deleted, - fs.last_sold, - ]) - if not self.viewing: - del fs.regular_price - del fs.current_price 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) - if os.path.exists(kwargs['image_path']): - kwargs['image'] = True + + 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. @@ -304,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)) @@ -316,208 +1763,996 @@ class ProductsView(MasterView): 'instance_title': self.get_instance_title(instance), 'form': form}) - def make_batch(self): - if self.request.method == 'POST': - provider = self.request.POST.get('provider') - if provider: - provider = batches.get_provider(self.rattail_config, provider) - if provider: + def get_version_child_classes(self): + model = self.model + return [ + (model.ProductCode, 'product_uuid'), + (model.ProductCost, 'product_uuid'), + (model.ProductPrice, 'product_uuid'), + ] - if self.request.POST.get('params') == 'True': - provider.set_params(Session(), **self.request.POST) + def image(self): + """ + View which renders the product's image as a response. + """ + product = self.get_instance() + if not product.image: + raise self.notfound() + # TODO: how to properly detect image type? + # 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 - else: - try: - url = self.request.route_url('batch_params.{}'.format(provider.name)) - except KeyError: - pass + 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(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(self.Session(), upc) + if product and (not product.deleted or self.request.has_perm('products.view_deleted')): + data = { + 'uuid': product.uuid, + '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, + require=False), + } + uuid = self.request.GET.get('with_vendor_cost') + if uuid: + vendor = self.Session.get(model.Vendor, uuid) + if not vendor: + return {'error': "Vendor not found"} + cost = product.cost_for_vendor(vendor) + if cost: + data['cost_found'] = True + if int(cost.case_size) == cost.case_size: + data['cost_case_size'] = int(cost.case_size) else: - self.request.session['referer'] = self.request.current_route_url() - return httpexceptions.HTTPFound(location=url) - - progress = SessionProgress(self.request, 'products.batch') - thread = Thread(target=self.make_batch_thread, args=(provider, progress)) + data['cost_case_size'] = '{:0.4f}'.format(cost.case_size) + else: + data['cost_found'] = False + return {'product': data} + + def get_supported_batches(self): + app = self.get_rattail_app() + pricing = app.get_batch_handler('pricing') + return OrderedDict([ + ('labels', { + 'spec': self.rattail_config.get('rattail.batch', 'labels.handler', + default='rattail.batch.labels:LabelBatchHandler'), + }), + ('pricing', { + 'spec': 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, 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.option_title)) + + 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') + + params_forms = {} + for key, handler in supported.items(): + 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': + 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: + 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 + + if fully_validated: + + # 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() - kwargs = { - 'key': 'products.batch', - 'cancel_url': self.request.route_url('products'), + return self.render_progress(progress, { + 'cancel_url': self.get_index_url(), 'cancel_msg': "Batch creation was canceled.", - } - return self.render_progress(kwargs) + }) - enabled = self.rattail_config.get('rattail.pyramid', 'batches.providers') - if enabled: - enabled = enabled.split() + return self.render_to_response('batch', { + 'form': form, + 'dform': form.make_deform_form(), # TODO: hacky? at least is explicit.. + 'params_forms': params_forms, + }) - providers = [] - for provider in batches.iter_providers(): - if not enabled or provider.name in enabled: - providers.append((provider.name, provider.description)) + 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() - return {'providers': providers} + def make_batch_params_schema_pricing(self): + """ + Return params schema for making a pricing batch. + """ + app = self.get_rattail_app() - def make_batch_thread(self, provider, progress): + 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() - products = self.get_effective_query(session) - batch = provider.make_batch(session, products, progress) - if not batch: + user = session.get(model.User, user_uuid) + assert user + params['created_by'] = user + try: + batch = handler.make_batch(session, **params) + batch.products = products.with_session(session).all() + handler.do_populate(batch, user, progress=progress) + + except Exception as error: session.rollback() + log.exception("failed to make '%s' batch with params: %s", + handler.batch_key, params) session.close() - return + 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() - session.commit() - session.refresh(batch) - session.close() + else: + session.commit() + session.refresh(batch) + session.close() - progress.session.load() - progress.session['complete'] = True - progress.session['success_url'] = self.request.route_url('batch.read', uuid=batch.uuid) - progress.session['success_msg'] = 'Batch "{}" has been created.'.format(batch.description) - progress.session.save() + 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): + cls._product_defaults(config) + cls._defaults(config) + + @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', "View products marked as deleted") # make batch from product query - config.add_route('products.create_batch', '/products/batch') - config.add_view(cls, attr='make_batch', route_name='products.create_batch', - renderer='/products/batch.mako', permission='batches.create') + config.add_tailbone_permission(permission_prefix, '{}.make_batch'.format(permission_prefix), + "Create batch from {} query".format(model_title)) + config.add_route('{}.make_batch'.format(route_prefix), '{}/make-batch'.format(url_prefix)) + config.add_view(cls, attr='make_batch', route_name='{}.make_batch'.format(route_prefix), + renderer='{}/batch.mako'.format(template_prefix), + permission='{}.make_batch'.format(permission_prefix)) - cls._defaults(config) + # search + config.add_route('products.search', '/products/search') + config.add_view(cls, attr='search', route_name='products.search', + 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)) + + # 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 ProductVersionView(VersionView): +class PendingProductView(MasterView): """ - View which shows version history for a product. + Master view for the Pending Product class. """ - parent_class = model.Product - route_model_view = 'product.read' - child_classes = [ - (model.ProductCode, 'product_uuid'), - (model.ProductCost, 'product_uuid'), - (model.ProductPrice, 'product_uuid'), - ] + model_class = PendingProduct + route_prefix = 'pending_products' + url_prefix = '/products/pending' + bulk_deletable = True - def warn_if_deleted(self): + labels = { + 'regular_price_amount': "Regular Price", + 'status_code': "Status", + 'user': "Created by", + } + + 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): """ - Maybe set flash warning if product is marked deleted. + Event hook, called just before deletion is attempted. """ - uuid = self.request.matchdict['uuid'] - product = Session.query(model.Product).get(uuid) - assert product, "No product found for UUID: {}".format(repr(uuid)) - if product.deleted: - self.request.session.flash("This product is marked as deleted.", 'error') + 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)) - def list(self): - self.warn_if_deleted() - return super(ProductVersionView, self).list() + 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 details(self): - self.warn_if_deleted() - return super(ProductVersionView, self).details() + def resolve_product(self): + model = self.model + pending = self.get_instance() + redirect = self.redirect(self.get_action_url('view', pending)) - -class ProductsAutocomplete(AutocompleteView): - """ - Autocomplete view for products. - """ - mapped_class = model.Product - fieldname = 'description' - - 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 - - def display(self, product): - return product.full_description - - -def products_search(request): - """ - 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. - """ - product = None - upc = request.GET.get('upc', '').strip() - upc = re.sub(r'\D', '', upc) - if upc: - product = api.get_product_by_upc(Session(), upc) + uuid = self.request.POST['product_uuid'] + product = self.Session.get(model.Product, uuid) 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) - if product: - if product.deleted and not request.has_perm('products.view_deleted'): - product = None - else: - product = { - 'uuid': product.uuid, - 'upc': unicode(product.upc or ''), - 'full_description': product.full_description, - } - return {'product': 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') - - config.add_route('products.search', '/products/search') - config.add_view(products_search, route_name='products.search', - renderer='json', permission='products.list') - - ProductsView.defaults(config) - version_defaults(config, ProductVersionView, 'product') + defaults(config) diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index de823e00..3f47ba3e 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.py @@ -1,43 +1,52 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ - """ Progress Views """ -from ..progress import get_progress_session +from tailbone.progress import get_progress_session def progress(request): key = request.matchdict['key'] - session = get_progress_session(request, key) + 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/__init__.py b/tailbone/views/purchases/__init__.py new file mode 100644 index 00000000..e615e240 --- /dev/null +++ b/tailbone/views/purchases/__init__.py @@ -0,0 +1,32 @@ +# -*- 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/>. +# +################################################################################ +""" +Views for purchase orders +""" + +from __future__ import unicode_literals, absolute_import + + +def includeme(config): + config.include('tailbone.views.purchases.core') + config.include('tailbone.views.purchases.credits') diff --git a/tailbone/views/purchases/core.py b/tailbone/views/purchases/core.py new file mode 100644 index 00000000..e7bebdff --- /dev/null +++ b/tailbone/views/purchases/core.py @@ -0,0 +1,422 @@ +# -*- 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 "true" purchase orders +""" + +from rattail.db import model + +from webhelpers2.html import HTML, tags + +from tailbone.db import Session +from tailbone.views import MasterView + + +class PurchaseView(MasterView): + """ + Master view for purchase orders. + """ + model_class = model.Purchase + creatable = False + + has_rows = True + model_row_class = model.PurchaseItem + row_model_title = 'Purchase Item' + + labels = { + 'id': "ID", + } + + grid_columns = [ + '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', + 'item_id', + 'brand_name', + 'description', + 'size', + 'cases_ordered', + 'units_ordered', + 'cases_received', + 'units_received', + 'po_total', + '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: + return "{} (invoiced {})".format(purchase.vendor, purchase.invoice_date.strftime('%Y-%m-%d')) + if purchase.date_received: + return "{} (invoiced {})".format(purchase.vendor, purchase.date_received.strftime('%Y-%m-%d')) + return "{} (invoiced)".format(purchase.vendor) + elif purchase.status >= self.enum.PURCHASE_STATUS_RECEIVED: + if purchase.date_received: + return "{} (received {})".format(purchase.vendor, purchase.date_received.strftime('%Y-%m-%d')) + return "{} (received)".format(purchase.vendor) + elif purchase.status >= self.enum.PURCHASE_STATUS_ORDERED: + if purchase.date_ordered: + return "{} (ordered {})".format(purchase.vendor, purchase.date_ordered.strftime('%Y-%m-%d')) + return "{} (ordered)".format(purchase.vendor) + return str(purchase) + + def configure_grid(self, g): + super().configure_grid(g) + model = self.model + + # 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) + + # 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') + + # 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) + + # buyer + g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person)) + g.set_sorter('buyer', model.Person.display_name) + g.set_filter('buyer', model.Person.display_name, + default_active=True, + default_verb='contains') + + # id + g.set_renderer('id', self.render_id_str) + + # date_ordered + g.filters['date_ordered'].default_active = True + g.filters['date_ordered'].default_verb = 'equal' + 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.") + + 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') + + if self.viewing: + purchase = f.model_instance + if purchase.status == self.enum.PURCHASE_STATUS_ORDERED: + 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): + """ + Delete all batches for the purchase, then delete the purchase. + """ + for batch in list(purchase.batches): + self.Session.delete(batch) + self.Session.flush() + self.Session.delete(purchase) + self.Session.flush() + + def get_parent(self, item): + return item.purchase + + def get_row_data(self, purchase): + return Session.query(model.PurchaseItem)\ + .filter(model.PurchaseItem.purchase == purchase) + + def configure_row_grid(self, g): + super().configure_row_grid(g) + + g.set_sort_defaults('sequence') + + g.set_type('cases_ordered', 'quantity') + g.set_type('units_ordered', '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_label('sequence', "Seq") + g.set_label('upc', "UPC") + g.set_label('brand_name', "Brand") + g.set_label('cases_ordered', "Cases Ord.") + g.set_label('units_ordered', "Units Ord.") + g.set_label('cases_received', "Cases Rec.") + g.set_label('units_received', "Units Rec.") + g.set_label('po_total', "Total") + g.set_label('invoice_total', "Total") + + purchase = self.get_instance() + if purchase.status == self.enum.PURCHASE_STATUS_ORDERED: + g.remove('cases_received', + 'units_received', + 'invoice_total') + elif purchase.status in (self.enum.PURCHASE_STATUS_RECEIVED, + self.enum.PURCHASE_STATUS_COSTED): + g.remove('po_total') + + def configure_row_form(self, f): + super().configure_row_form(f) + + # 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') + + # 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() + return self.render_to_response('receiving_worksheet', { + 'purchase': purchase, + }) + + @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() + + # 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): + defaults(config) diff --git a/tailbone/views/purchases/credits.py b/tailbone/views/purchases/credits.py new file mode 100644 index 00000000..7da096eb --- /dev/null +++ b/tailbone/views/purchases/credits.py @@ -0,0 +1,212 @@ +# -*- 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 "true" purchase credits +""" + +from rattail.db import model + +from webhelpers2.html import tags + +from tailbone import grids +from tailbone.views import MasterView + + +class PurchaseCreditView(MasterView): + """ + Master view for purchase credits + """ + model_class = model.PurchaseCredit + route_prefix = 'purchases.credits' + url_prefix = '/purchases/credits' + creatable = False + 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', + ] + + 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.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' + # TODO: should not have to convert value to string! + g.filters['status'].default_value = str(self.enum.PURCHASE_CREDIT_STATUS_SATISFIED) + + # 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('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('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') + return self.redirect(self.get_index_url()) + + status = self.request.POST.get('status', '') + if status.isdigit(): + status = int(status) + else: + self.request.session.flash("Received invalid status: {}".format(status), 'error') + return self.redirect(self.get_index_url()) + + credits_ = [] + for uuid in self.request.POST.get('uuids', '').split(','): + uuid = uuid.strip() + if uuid: + credit = self.Session.get(model.PurchaseCredit, uuid) + if credit: + credits_.append(credit) + if not credits_: + self.request.session.flash("Received zero valid credits", 'error') + return self.redirect(self.get_index_url()) + + # okay, really change status + for credit in credits_: + credit.status = status + + self.request.session.flash("Changed status for {} credits".format(len(credits_))) + return self.redirect(self.get_index_url()) + + def template_kwargs_index(self, **kwargs): + kwargs['status_options'] = self.status_options() + return kwargs + + def status_options(self): + options = [] + for value in sorted(self.enum.PURCHASE_CREDIT_STATUS): + 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)) + config.add_route('{}.change_status'.format(route_prefix), '{}/change-status'.format(url_prefix)) + config.add_view(cls, attr='change_status', route_name='{}.change_status'.format(route_prefix), + permission='{}.change_status'.format(permission_prefix)) + + +def defaults(config, **kwargs): + base = globals() + + PurchaseCreditView = kwargs.get('PurchaseCreditView', base['PurchaseCreditView']) + PurchaseCreditView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/purchasing/__init__.py b/tailbone/views/purchasing/__init__.py new file mode 100644 index 00000000..09d62909 --- /dev/null +++ b/tailbone/views/purchasing/__init__.py @@ -0,0 +1,35 @@ +# -*- 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 purchasing batches +""" + +from __future__ import unicode_literals, absolute_import + +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 new file mode 100644 index 00000000..5e00704e --- /dev/null +++ b/tailbone/views/purchasing/batch.py @@ -0,0 +1,1197 @@ +# -*- 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 purchasing batch views +""" + +import warnings + +from rattail.db.model import PurchaseBatch, PurchaseBatchRow + +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags, HTML + +from tailbone import forms +from tailbone.views.batch import BatchMasterView + + +class PurchasingBatchView(BatchMasterView): + """ + Master view base class, for purchase batches. The views for both + "ordering" and "receiving" batches will inherit from this. + """ + 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', + 'vendor', + 'department', + 'buyer', + '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', + # # 'item_id', + # 'brand_name', + # 'description', + # 'size', + # 'cases_ordered', + # 'units_ordered', + # 'cases_received', + # 'units_received', + # 'po_total', + # 'invoice_total', + # 'credits', + # '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().configure_grid(g) + model = self.model + + # 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') + + # department + g.set_joiner('department', lambda q: q.join(model.Department)) + g.set_filter('department', model.Department.name) + g.set_sorter('department', model.Department.name) + + g.set_joiner('buyer', lambda q: q.join(model.Employee).join(model.Person)) + g.set_filter('buyer', model.Person.display_name) + g.set_sorter('buyer', model.Person.display_name) + + # TODO: we used to include the 'complete' filter by default, but it + # seems to likely be confusing for newcomers, so it is no longer + # 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") + + def grid_extra_class(self, batch, i): + if batch.status_code == batch.STATUS_UNKNOWN_PRODUCT: + return 'notice' + +# def make_form(self, batch, **kwargs): +# if self.creating: +# kwargs.setdefault('id', 'new-purchase-form') +# form = super(PurchasingBatchView, self).make_form(batch, **kwargs) +# return form + + def configure_common_form(self, f): + super().configure_common_form(f) + + # 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] + + def get_vendor_phone_number(self, batch): + for phone in batch.vendor.phones: + if phone.type == 'Voice': + return phone.number + + def get_vendor_fax_number(self, batch): + for phone in batch.vendor.phones: + if phone.type == 'Fax': + return phone.number + + def get_batch_kwargs(self, batch, **kwargs): + kwargs = super().get_batch_kwargs(batch, **kwargs) + model = self.app.model + + kwargs['mode'] = self.batch_mode + kwargs['workflow'] = self.request.POST['workflow'] + kwargs['truck_dump'] = batch.truck_dump + kwargs['order_parser_key'] = batch.order_parser_key + kwargs['invoice_parser_key'] = batch.invoice_parser_key + + if batch.store: + 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 + + # TODO: should these always get set? + if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: + kwargs['date_ordered'] = batch.date_ordered + elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: + kwargs['date_ordered'] = batch.date_ordered + kwargs['date_received'] = batch.date_received + kwargs['invoice_number'] = batch.invoice_number + elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_COSTING: + kwargs['invoice_date'] = batch.invoice_date + kwargs['invoice_number'] = batch.invoice_number + + if self.batch_mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING, + self.enum.PURCHASE_BATCH_MODE_COSTING): + 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 + +# def template_kwargs_view(self, **kwargs): +# kwargs = super(PurchasingBatchView, self).template_kwargs_view(**kwargs) +# vendor = kwargs['batch'].vendor +# kwargs['vendor_cost_count'] = Session.query(model.ProductCost)\ +# .filter(model.ProductCost.vendor == vendor)\ +# .count() +# kwargs['vendor_cost_threshold'] = self.rattail_config.getint( +# 'tailbone', 'purchases.order_form.vendor_cost_warning_threshold', default=699) +# return kwargs + + def template_kwargs_create(self, **kwargs): + kwargs['purchases_field'] = 'purchase_uuid' + return kwargs + +# def get_row_data(self, batch): +# query = super(PurchasingBatchView, self).get_row_data(batch) +# return query.options(orm.joinedload(model.PurchaseBatchRow.credits)) + + def configure_row_grid(self, 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('po_total_calculated', 'currency') + g.set_type('credits', 'boolean') + + # 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.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 in (row.STATUS_PRODUCT_NOT_FOUND, + row.STATUS_COST_NOT_FOUND): + return 'warning' + 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 configure_row_form(self, f): + super().configure_row_form(f) + row = f.model_instance + if self.creating: + batch = self.get_instance() + else: + batch = self.get_parent(row) + + # 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: + f.remove_fields( + 'upc', + 'product', + 'po_total', + 'invoice_total', + ) + if self.batch_mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: + f.remove_fields('cases_received', + 'units_received') + elif self.batch_mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: + f.remove_fields('cases_ordered', + 'units_ordered') + + elif self.editing: + 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: + if row.product: + f.remove_fields('brand_name', + 'description', + 'size') + f.set_renderer('product', self.render_product) + else: + 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 +# batch = self.get_instance() +# batch.add_row(row) +# # TODO: this seems heavy-handed but works.. +# row.product_uuid = self.item_lookup(form.fieldset.item_lookup.value) + +# def after_create_row(self, row): +# self.handler.refresh_row(row) + + def save_edit_row_form(self, form): + """ + Supplements or overrides the default logic, as follows: + + *Ordering Mode* + + 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()) + + # 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 +# if isinstance(result, model.Purchase): +# return self.request.route_url('purchases.view', uuid=result.uuid) + +# # 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 _purchase_batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_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') + + +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 new file mode 100644 index 00000000..c7cc7bfc --- /dev/null +++ b/tailbone/views/purchasing/ordering.py @@ -0,0 +1,608 @@ +# -*- 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 'ordering' (purchasing) batches +""" + +import os +import json + +import openpyxl + +from rattail.core import Object + +from tailbone.db import Session +from tailbone.views.purchasing import PurchasingBatchView + + +class OrderingBatchView(PurchasingBatchView): + """ + Master view for "ordering" batches. + """ + route_prefix = 'ordering' + url_prefix = '/ordering' + model_title = "Ordering Batch" + model_title_plural = "Ordering Batches" + index_title = "Ordering" + rows_editable = True + has_worksheet = True + default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html' + downloadable = True + configurable = True + + labels = { + 'po_total_calculated': "PO Total", + } + + 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', + 'upc', + # 'item_id', + 'brand_name', + 'description', + 'size', + 'cases_ordered', + 'units_ordered', + # 'cases_received', + # 'units_received', + '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", + "Description", + "Case", + "Vend. Code", + "Pref.", + "Unit Cost", + ] + + @property + def batch_mode(self): + return self.enum.PURCHASE_BATCH_MODE_ORDERING + + 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): + """ + 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: + return self.redirect(self.get_action_url('view', batch)) + + # 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.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 + if department: + departments.setdefault(department.uuid, department) + else: + if None not in departments: + department = Object(name='', number=None) + departments[None] = department + department = departments[None] + + subdepartments = getattr(department, '_order_subdepartments', None) + if subdepartments is None: + subdepartments = department._order_subdepartments = {} + + subdepartment = cost.product.subdepartment + if subdepartment: + subdepartments.setdefault(subdepartment.uuid, subdepartment) + else: + if None not in subdepartments: + subdepartment = Object(name=None, number=None) + subdepartments[None] = subdepartment + subdepartment = subdepartments[None] + + subdept_costs = getattr(subdepartment, '_order_costs', None) + if subdept_costs is None: + subdept_costs = subdepartment._order_costs = [] + subdept_costs.append(cost) + cost._batchrow = order_items.get(cost.product_uuid) + + # fetch recent purchase history, sort/pad for template convenience + 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) + 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), + 'vendor': batch.vendor, + 'departments': departments, + 'history': history, + 'get_upc': lambda p: p.upc.pretty() if p.upc else '', + 'header_columns': self.order_form_header_columns, + 'ignore_cases': not self.handler.allow_cases(), + 'worksheet_data': self.get_worksheet_data(departments), + }) + + 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 + + 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 + + return data + + def worksheet_update(self): + """ + 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() + + 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)} + + 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)} + + uuid = data.get('product_uuid') + product = self.Session.get(model.Product, uuid) if uuid else None + if not product: + return {'error': "Product not found"} + + # 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)) + + 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': 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 download_excel(self): + """ + Download ordering batch as Excel spreadsheet. + """ + batch = self.get_instance() + + # populate Excel worksheet + workbook = openpyxl.Workbook() + worksheet = workbook.active + worksheet.title = "Purchase Order" + worksheet.append(["Store", "Vendor", "Date ordered"]) + 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, str(row.upc), row.brand_name, + '{} {}'.format(row.description, row.size), + row.cases_ordered, row.units_ordered]) + + # write Excel file to batch data dir + filedir = batch.filedir(self.rattail_config) + if not os.path.exists(filedir): + os.makedirs(filedir) + filename = 'PO.{}.xlsx'.format(batch.id_str) + path = batch.filepath(self.rattail_config, filename) + workbook.save(path) + + 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): + cls._ordering_defaults(config) + cls._purchase_batch_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) + + @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), '{}/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 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 new file mode 100644 index 00000000..01858c98 --- /dev/null +++ b/tailbone/views/purchasing/receiving.py @@ -0,0 +1,2024 @@ +# -*- 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 'receiving' (purchasing) batches +""" + +import os +import decimal +import logging +from collections import OrderedDict + +# import humanize + +from rattail import pod +from rattail.util import simple_error + +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags, HTML + +from wuttaweb.util import get_form_data + +from tailbone import forms +from tailbone.views.purchasing import PurchasingBatchView + + +log = logging.getLogger(__name__) + +POSSIBLE_RECEIVING_MODES = [ + 'received', + 'damaged', + 'expired', + # 'mispick', + 'missing', +] + +POSSIBLE_CREDIT_TYPES = [ + 'damaged', + 'expired', + # 'mispick', + 'missing', +] + + +class ReceivingBatchView(PurchasingBatchView): + """ + Master view for receiving batches + """ + route_prefix = 'receiving' + url_prefix = '/receiving' + model_title = "Receiving Batch" + model_title_plural = "Receiving Batches" + index_title = "Receiving" + 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', + '_product_key_', + 'vendor_code', + 'brand_name', + 'description', + 'size', + 'department_name', + 'cases_ordered', + 'units_ordered', + 'cases_shipped', + 'units_shipped', + 'cases_received', + 'units_received', + '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 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 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() + + # 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_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('{}.create'.format(route_prefix)) + + # 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') + + # truck_dump* + if allow_truck_dump: + + # truck_dump + if self.creating or not batch.is_truck_dump_parent(): + f.remove_field('truck_dump') + else: + f.set_readonly('truck_dump') + + # truck_dump_children_first + if self.creating or not batch.is_truck_dump_parent(): + f.remove_field('truck_dump_children_first') + + # 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: + 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 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: + raise NotImplementedError + return kwargs + + def make_po_vs_invoice_breakdown(self, batch): + """ + Returns a simple breakdown as list of 2-tuples, each of which + has the display title as first member, and number of rows as + second member. + """ + grouped = {} + labels = OrderedDict([ + ('both', "Found in both PO and Invoice"), + ('po_not_invoice', "Found in PO but not Invoice"), + ('invoice_not_po', "Found in Invoice but not PO"), + ('neither', "Not found in PO nor Invoice"), + ]) + + for row in batch.active_rows(): + if row.po_line_number and not row.invoice_line_number: + grouped.setdefault('po_not_invoice', []).append(row) + elif row.invoice_line_number and not row.po_line_number: + grouped.setdefault('invoice_not_po', []).append(row) + elif row.po_line_number and row.invoice_line_number: + grouped.setdefault('both', []).append(row) + else: + grouped.setdefault('neither', []).append(row) + + breakdown = [] + + for key, label in labels.items(): + if key in grouped: + breakdown.append({ + 'key': key, + 'title': label, + 'count': len(grouped[key]), + }) + + return breakdown + + def allow_edit_catalog_unit_cost(self, batch): + + # batch must not yet be frozen + if batch.executed or batch.complete: + return False + + # user must have edit_row perm + if not self.has_perm('edit_row'): + return False + + # config must allow this generally + if not self.batch_handler.allow_receiving_edit_catalog_unit_cost(): + return False + + return True + + def allow_edit_invoice_unit_cost(self, batch): + + # batch must not yet be frozen + if batch.executed or batch.complete: + return False + + # user must have edit_row perm + if not self.has_perm('edit_row'): + return False + + # config must allow this generally + if not self.batch_handler.allow_receiving_edit_invoice_unit_cost(): + return False + + return True + + def template_kwargs_view(self, **kwargs): + kwargs = super().template_kwargs_view(**kwargs) + batch = kwargs['instance'] + + if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch): + breakdown = self.make_po_vs_invoice_breakdown(batch) + factory = self.get_grid_factory() + + g = factory(self.request, + key='batch_po_vs_invoice_breakdown', + data=[], + columns=['title', 'count']) + g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)") + kwargs['po_vs_invoice_breakdown_data'] = breakdown + kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal( + g.render_table_element(data_prop='poVsInvoiceBreakdownData', + empty_labels=True)) + + kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch) + kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch) + + if (kwargs['allow_edit_catalog_unit_cost'] + and kwargs['allow_edit_invoice_unit_cost'] + and not batch.get_param('confirmed_all_costs')): + kwargs['allow_confirm_all_costs'] = True + else: + kwargs['allow_confirm_all_costs'] = False + + return kwargs + + def get_context_credits(self, row): + app = self.get_rattail_app() + credits_data = [] + for credit in row.credits: + credits_data.append({ + 'uuid': credit.uuid, + 'credit_type': credit.credit_type, + 'expiration_date': str(credit.expiration_date) if credit.expiration_date else None, + 'cases_shorted': app.render_quantity(credit.cases_shorted), + 'units_shorted': app.render_quantity(credit.units_shorted), + 'shorted': app.render_cases_units(credit.cases_shorted, + credit.units_shorted), + 'credit_total': app.render_currency(credit.credit_total), + 'mispick_upc': '-', + 'mispick_brand_name': '-', + 'mispick_description': '-', + 'mispick_size': '-', + }) + return credits_data + + def template_kwargs_view_row(self, **kwargs): + kwargs = super().template_kwargs_view_row(**kwargs) + app = self.get_rattail_app() + products_handler = app.get_products_handler() + row = kwargs['instance'] + + kwargs['allow_cases'] = self.batch_handler.allow_cases() + + if row.product: + kwargs['image_url'] = products_handler.get_image_url(row.product) + elif row.upc: + kwargs['image_url'] = products_handler.get_image_url(upc=row.upc) + + kwargs['row_context'] = self.get_context_row(row) + + modes = list(POSSIBLE_RECEIVING_MODES) + types = list(POSSIBLE_CREDIT_TYPES) + if not self.batch_handler.allow_expired_credits(): + if 'expired' in modes: + modes.remove('expired') + if 'expired' in types: + types.remove('expired') + kwargs['possible_receiving_modes'] = modes + kwargs['possible_credit_types'] = types + + return kwargs + + def department_for_purchase(self, purchase): + pass + + 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() + 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) + + 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) + + def configure_child_from_invoice_form(self, f): + assert self.creating + truck_dump = self.get_instance() + + self.configure_form(f) + + # 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) + + # 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) + + def render_simple_unit_cost(self, row, field): + value = getattr(row, field) + if value is None: + return + + # TODO: if anyone ever wants to see "raw" costs displayed, + # should make this configurable, b/c some folks already wanted + # the shorter 2-decimal display + #return str(value) + + app = self.get_rattail_app() + return app.render_currency(value) + + def render_catalog_unit_cost(self): + return HTML.tag('receiving-cost-editor', **{ + 'field': 'catalog_unit_cost', + 'v-model': 'props.row.catalog_unit_cost', + ':ref': "'catalogUnitCost_' + props.row.uuid", + ':row': 'props.row', + '@input': 'catalogCostConfirmed', + }) + + def render_invoice_unit_cost(self): + return HTML.tag('receiving-cost-editor', **{ + 'field': 'invoice_unit_cost', + 'v-model': 'props.row.invoice_unit_cost', + ':ref': "'invoiceUnitCost_' + props.row.uuid", + ':row': 'props.row', + '@input': 'invoiceCostConfirmed', + }) + + def row_grid_extra_class(self, row, i): + css_class = super().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): + """ + 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() + + # 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': 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, + } + + schema = ReceiveRowForm().bind(session=self.Session()) + form = forms.Form(schema=schema, request=self.request) + form.cancel_url = self.get_row_action_url('view', row) + + # mode + mode_values = [(mode, mode) for mode in possible_modes] + mode_widget = dfwidget.SelectWidget(values=mode_values) + form.set_widget('mode', mode_widget) + + # quantity + form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True, + one_amount_only=True)) + form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity) + + # expiration_date + form.set_type('expiration_date', 'date_jquery') + + # TODO: what is this one about again? + form.remove_field('quick_receive') + + if form.validate(): + + # 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)}) + + batch = row.batch + 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): + 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() + + # 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)) + + # 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 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): + defaults(config) diff --git a/tailbone/views/reportcodes.py b/tailbone/views/reportcodes.py index 91662166..ef090c22 100644 --- a/tailbone/views/reportcodes.py +++ b/tailbone/views/reportcodes.py @@ -1,23 +1,23 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ @@ -26,37 +26,93 @@ Report Code Views from __future__ import unicode_literals, absolute_import -from tailbone.views import MasterView - from rattail.db import model +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(ReportCodeView, self).configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'code' - g.configure( - include=[ - g.code, - g.name, - ], - readonly=True) + 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 6a8df2a2..099224be 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -1,61 +1,72 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ -Report Views +Reporting views """ -from __future__ import unicode_literals - +import calendar +import json import re - -from .core import View -from mako.template import Template -from pyramid.response import Response - -from ..db import Session +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, 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]) @@ -69,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' @@ -92,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) @@ -115,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, @@ -124,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) @@ -144,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 @@ -165,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) @@ -178,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'), @@ -192,16 +208,647 @@ class InventoryWorksheet(View): return template.render(**data) +class ReportOutputView(ExportMasterView): + """ + Master view for report output + """ + 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', + 'report_name', + 'filename', + 'created', + '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().configure_grid(g) + + g.filters['report_name'].default_active = True + g.filters['report_name'].default_verb = 'contains' + + g.set_link('filename') + + def configure_form(self, f): + super().configure_form(f) + + # 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 ee666f14..e8a6d8a2 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -1,122 +1,567 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Role Views """ -from __future__ import unicode_literals, absolute_import +import os -from rattail.db import model -from rattail.db.auth import has_permission, administrator_role, guest_role, authenticated_role +from sqlalchemy import orm +from openpyxl.styles import Font, PatternFill -import formalchemy -from webhelpers.html import HTML, tags +from rattail.db.model import Role +from rattail.excel import ExcelWriter -from tailbone import forms +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML + +from tailbone import grids from tailbone.db import Session -from tailbone.views import MasterView -from tailbone.views.continuum import VersionView, version_defaults -from tailbone.newgrids import AlchemyGrid, GridAction +from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer -class PermissionsField(formalchemy.Field): - """ - Custom field for role permissions. - """ - - def sync(self): - if not self.is_readonly(): - role = self.model - role.permissions = self.renderer.deserialize() - - -class RolesView(MasterView): +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().configure_grid(g) + + # name g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'name' - g.configure( - include=[ - g.name, - ], - readonly=True) + g.set_sort_defaults('name') + g.set_link('name') - def configure_fieldset(self, fs): - fs.append(PermissionsField('permissions')) - permissions = self.request.registry.settings.get('tailbone_permissions', {}) - fs.permissions.set(renderer=forms.renderers.PermissionsFieldRenderer(permissions)) - fs.configure( - include=[ - fs.name, - fs.permissions, - ]) + # notes + g.set_renderer('notes', self.render_short_notes) + + 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: - - # TODO: This is the first attempt at using a new grid outside of - # the context of a primary master grid. The API here is really - # much hairier than I'd like... Looks like we shouldn't need a key - # for this one, for instance (no settings required), but there is - # plenty of room for improvement here. users = sorted(role.users, key=lambda u: u.username) - users = AlchemyGrid('roles.users', self.request, data=users, model_class=model.User, - main_actions=[ - GridAction('view', icon='zoomin', - url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid)), - ]) - users.configure(include=[users.username], readonly=True) - kwargs['users'] = users - + actions = [ + self.make_action('view', icon='zoomin', + url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid)) + ] + 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() -class RoleVersionView(VersionView): - """ - View which shows version history for a role. - """ - parent_class = model.Role - route_model_view = 'roles.view' + # 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 auth.has_permission(session, role, permission, include_anonymous=False): + roles.append(role) + return roles + + def find_by_perm_configure_results_grid(self, g): + g.append('name') + g.set_link('name') + + def find_by_perm_normalize(self, role): + data = super().find_by_perm_normalize(role) + + data['name'] = role.name + + return data + + def download_permissions_matrix(self): + """ + View which renders the complete role / permissions matrix data into an + Excel spreadsheet, and returns that file. + """ + app = self.get_rattail_app() + model = self.model + auth = app.get_auth_handler() + + roles = self.Session.query(model.Role)\ + .order_by(model.Role.name)\ + .all() + + 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 PermissionsWidget(dfwidget.Widget): + template = 'permissions' + permissions = None + true_val = 'true' + + 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) - version_defaults(config, RoleVersionView, 'role') + defaults(config) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index f02f5e3c..10a0c2eb 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -1,62 +1,473 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Settings Views """ -from __future__ import unicode_literals +import json +import re -from rattail.db import model +import colander -from tailbone.views import 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 = [ + 'name', + 'value', + ] def configure_grid(self, g): + super().configure_grid(g) g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.default_sortkey = 'name' - g.configure( - include=[ - g.name, - g.value, - ], - readonly=True) + g.set_sort_defaults('name') + g.set_link('name') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.name, - fs.value, - ]) - if self.editing: - fs.name.set(readonly=True) + def configure_form(self, f): + super().configure_form(f) + if self.creating: + f.set_validator('name', self.unique_name) + + def unique_name(self, node, value): + model = self.model + setting = self.Session.get(model.Setting, value) + if setting: + raise colander.Invalid(node, "Setting name must be unique") + + def editable_instance(self, setting): + if self.rattail_config.demo(): + 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/__init__.py b/tailbone/views/shifts/__init__.py index 208014c1..3b6fb5fb 100644 --- a/tailbone/views/shifts/__init__.py +++ b/tailbone/views/shifts/__init__.py @@ -2,22 +2,22 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# Copyright © 2010-2017 Lance Edgar # # This file is part of Rattail. # # Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index 2023e871..53bfc446 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.py @@ -1,125 +1,225 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 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.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)]) -class ScheduledShiftsView(MasterView): +class ScheduledShiftView(MasterView, ShiftViewMixin): """ Master view for employee scheduled shifts. """ model_class = model.ScheduledShift url_prefix = '/shifts/scheduled' + grid_columns = [ + 'employee', + 'store', + 'start_time', + 'end_time', + 'length', + ] + + form_fields = [ + 'employee', + 'store', + 'start_time', + 'end_time', + 'length', + ] + def configure_grid(self, g): - g.default_sortkey = 'start_time' - g.default_sortdir = 'desc' - g.append(ShiftLengthField('length')) - g.configure( - include=[ - g.employee, - g.store, - g.start_time, - g.end_time, - g.length, - ], - readonly=True) + 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') - def configure_fieldset(self, fs): - fs.append(ShiftLengthField('length')) - fs.configure( - include=[ - fs.employee, - fs.store, - fs.start_time, - fs.end_time, - fs.length, - ]) + g.set_sort_defaults('start_time', 'desc') + + g.set_renderer('length', self.render_shift_length) + + g.set_label('employee', "Employee Name") + + 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 = [ + 'employee', + 'store', + 'start_time', + 'end_time', + 'length', + ] + + form_fields = [ + 'employee', + 'store', + 'start_time', + 'end_time', + 'length', + ] def configure_grid(self, g): - # 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.append(ShiftLengthField('length')) - g.configure( - include=[ - g.employee, - g.store, - g.start_time, - g.end_time, - g.length, - ], - readonly=True) + super().configure_grid(g) + model = self.model - def configure_fieldset(self, fs): - fs.append(ShiftLengthField('length')) - fs.configure( - include=[ - fs.employee, - fs.store, - fs.start_time, - fs.end_time, - fs.length, - ]) + # 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) + + # 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.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', self.render_shift_length) + + g.set_label('employee', "Employee Name") + g.set_label('store', "Store Name") + g.set_label('punch_in', "Start Time") + g.set_label('punch_out', "End Time") + + def get_instance_title(self, shift): + time = shift.start_time or shift.end_time + date = localtime(self.rattail_config, time).date() + return "WorkedShift: {}, {}".format(shift.employee, date) + + def configure_form(self, f): + super().configure_form(f) + + f.set_readonly('employee') + f.set_renderer('employee', self.render_employee) + + f.set_renderer('length', self.render_shift_length) + if self.editing: + 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 3cb8b0ef..1827bee0 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -1,58 +1,60 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 time sheets """ -from __future__ import unicode_literals, absolute_import - import datetime -from rattail import enum -from rattail.db import model, api -from rattail.time import localtime, make_utc, get_sunday +import sqlalchemy as sa -import formencode as fe -from pyramid_simpleform import Form +from rattail.db import api +from rattail.time import get_sunday +from rattail.util import hours_as_decimal + +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags, HTML from tailbone import forms 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): @@ -62,6 +64,7 @@ class TimeSheetView(View): key = None title = None model_class = None + expose_employee_views = True # Set this to False to avoid the default behavior of auto-filtering by # current store. @@ -71,110 +74,195 @@ class TimeSheetView(View): def get_title(cls): return cls.title or cls.key.capitalize() - def full(self): + @classmethod + def get_url_prefix(cls): + return getattr(cls, 'url_prefix', cls.key).rstrip('/') + + def get_timesheet_context(self): + """ + 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: + date_value = self.request.session.get(date_key) + if date_value: + try: + date = datetime.datetime.strptime(date_value, '%m/%d/%Y').date() + except ValueError: + pass + if not date: + date = app.today() + store = None department = None + store_key = 'timesheet.{}.store'.format(self.key) + department_key = 'timesheet.{}.department'.format(self.key) + 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.get(model.Store, store_uuid) if store_uuid else None + department_uuid = self.request.session.get(department_key) + if 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') + if store: + store = api.get_store(Session(), store) + employees = Session.query(model.Employee)\ - .filter(model.Employee.status == enum.EMPLOYEE_STATUS_CURRENT) - - form = Form(self.request, schema=ShiftFilter) - 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 - return self.redirect(self.request.current_route_url()) - - else: - store_key = 'timesheet.{}.store'.format(self.key) - department_key = 'timesheet.{}.department'.format(self.key) - 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 - department_uuid = self.request.session.get(department_key) - if department_uuid: - department = Session.query(model.Department).get(department_uuid) - else: # no store/department in session - if self.default_filter_store: - store = self.rattail_config.get('rattail', 'store') - if store: - store = api.get_store(Session(), store) - - date_key = 'timesheet.{}.date'.format(self.key) - if date_key in self.request.session: - date_value = self.request.session.get(date_key) - if date_value: - try: - date = datetime.datetime.strptime(date_value, '%m/%d/%Y').date() - except ValueError: - pass - + .filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT) if store: employees = employees.join(model.EmployeeStore)\ .filter(model.EmployeeStore.store == store) - if department: employees = employees.join(model.EmployeeDepartment)\ .filter(model.EmployeeDepartment.department == department) - if not date: - date = localtime(self.rattail_config).date() + return { + 'date': date, + 'store': store, + 'department': department, + 'employees': employees.all(), + } - return self.render_full(date, employees.all(), store=store, department=department, form=form) + def get_employee_context(self): + """ + 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: + date_value = self.request.session.get(date_key) + if date_value: + try: + date = datetime.datetime.strptime(date_value, '%m/%d/%Y').date() + except ValueError: + pass + if not 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.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 + + # 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): + """ + Process a "shift filter" form if one was in fact POST'ed. If it was + then we store new context in session and redirect to display as normal. + """ + if form.validate(): + 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 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. + """ + 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. """ - date = None - employee = None - if not self.request.has_perm('{}.viewall'.format(self.key)): - # force current user if not allowed to view all data - employee = self.request.user.employee - assert employee - form = Form(self.request, schema=EmployeeShiftFilter) - if self.request.method == 'POST': - if form.validate(): - if self.request.has_perm('{}.viewall'.format(self.key)): - 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 - return self.redirect(self.request.current_route_url()) - - else: - if self.request.has_perm('{}.viewall'.format(self.key)): - employee_key = 'timesheet.{}.employee'.format(self.key) - if employee_key in self.request.session: - employee_uuid = self.request.session.get(employee_key) - if employee_uuid: - employee = Session.query(model.Employee).get(employee_uuid) - else: # no employee in session - if self.request.user: - employee = self.request.user.employee - - date_key = 'timesheet.{}.employee.date'.format(self.key) - if date_key in self.request.session: - date_value = self.request.session.get(date_key) - if date_value: - try: - date = datetime.datetime.strptime(date_value, '%m/%d/%Y').date() - except ValueError: - pass - - # default to current user; force unless allowed to view all data - if not employee or not self.request.has_perm('{}.viewall'.format(self.key)): - employee = self.request.user.employee - assert employee - - if not date: - date = localtime(self.rattail_config).date() - return self.render_single(date, employee, 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) def crossview(self): """ @@ -196,17 +284,6 @@ class TimeSheetView(View): self.session_put('date', self.session_get('date'), mainkey=other_key) return self.redirect(self.request.route_url(other_key)) - # def session_has(self, key, mainkey=None): - # if mainkey is None: - # mainkey = self.key - # return 'timesheet.{}.{}'.format(mainkey, key) in self.request.session - - # def session_has_any(self, *keys, **kwargs): - # for key in keys: - # if self.session_has(key, **kwargs): - # return True - # return False - def session_get(self, key, mainkey=None): if mainkey is None: mainkey = self.key @@ -218,22 +295,22 @@ 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): - options = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores] - options.insert(0, ('', "(all)")) - return options + options = [tags.Option("{} - {}".format(s.id, s.name), s.uuid) for s in stores] + 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): - options = [(d.uuid, d.name) for d in departments] - options.insert(0, ('', "(all)")) - return options + options = [tags.Option(d.name, d.uuid) for d in departments] + return tags.Options(options, prompt="(all)") - def render_full(self, date, employees, store=None, department=None, form=None): + def render_full(self, date=None, employees=None, store=None, department=None, form=None, **kwargs): """ Render a time sheet for one or more employees, for the week which includes the specified date. @@ -257,9 +334,10 @@ class TimeSheetView(View): departments = self.get_departments() department_options = self.get_department_options(departments) - return { - 'page_title': "Full {}".format(self.get_title()), - 'form': forms.FormRenderer(form) if form else None, + context = { + 'page_title': self.get_title_full(), + 'form': form, + 'dform': form.make_deform_form() if form else None, 'employees': employees, 'stores': stores, 'store_options': store_options, @@ -275,11 +353,16 @@ class TimeSheetView(View): 'permission_prefix': self.key, 'render_shift': self.render_shift, } + context.update(kwargs) + return context + + def get_title_full(self): + return "Full {}".format(self.get_title()) def render_shift(self, shift): - return shift.get_display(self.rattail_config) + return HTML.tag('span', c=shift.get_display(self.rattail_config)) - def render_single(self, date, employee, form=None): + def render_single(self, date=None, employee=None, form=None, **kwargs): """ Render a time sheet for one employee, for the week which includes the specified date. @@ -297,9 +380,11 @@ class TimeSheetView(View): self.modify_employees([employee], weekdays) - return { + 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, @@ -310,28 +395,61 @@ class TimeSheetView(View): 'permission_prefix': self.key, 'render_shift': self.render_shift, } + context.update(kwargs) + return context def modify_employees(self, employees, weekdays): - 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))) - shifts = Session.query(self.model_class)\ - .filter(self.model_class.employee_uuid.in_([e.uuid for e in employees]))\ - .filter(self.model_class.start_time >= make_utc(min_time))\ - .filter(self.model_class.start_time < make_utc(max_time))\ + self.fetch_shift_data(self.model_class, employees, weekdays) + + def fetch_shift_data(self, cls, employees, weekdays): + """ + 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' + if self.request.user: + hours_style = self.rattail_config.get('tailbone', 'hours_style.{}'.format(self.request.user.username), + default='pretty') + if hours_style != 'decimal': + hours_style = 'pretty' + + shift_type = 'scheduled' if cls is model.ScheduledShift else 'worked' + 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 >= app.make_utc(min_time), + cls.start_time < app.make_utc(max_time), + ), + sa.and_( + cls.start_time == None, + cls.end_time >= app.make_utc(min_time), + cls.end_time < app.make_utc(max_time), + )))\ .all() for employee in employees: employee_shifts = sorted([s for s in shifts if s.employee_uuid == employee.uuid], - key=lambda s: (s.start_time, s.end_time)) - employee.weekdays = [] - employee.hours = datetime.timedelta(0) - employee.hours_display = '0' + key=lambda s: s.start_time or s.end_time) + if not hasattr(employee, 'weekdays'): + employee.weekdays = [{} for day in weekdays] + setattr(employee, '{}_hours'.format(shift_type), datetime.timedelta(0)) + setattr(employee, '{}_hours_display'.format(shift_type), '0') + hours_incomplete = False - for day in weekdays: + for i, day in enumerate(weekdays): empday = { - 'shifts': [], - 'hours': datetime.timedelta(0), - 'hours_display': '', + '{}_shifts'.format(shift_type): [], + '{}_hours'.format(shift_type): datetime.timedelta(0), + '{}_hours_display'.format(shift_type): '', + 'hours_incomplete': False, } while employee_shifts: @@ -339,23 +457,39 @@ class TimeSheetView(View): if shift.employee_uuid != employee.uuid: break elif shift.get_date(self.rattail_config) == day: - empday['shifts'].append(shift) + empday['{}_shifts'.format(shift_type)].append(shift) length = shift.length if length is not None: - empday['hours'] += shift.length - employee.hours += shift.length + empday['{}_hours'.format(shift_type)] += shift.length + setattr(employee, '{}_hours'.format(shift_type), + getattr(employee, '{}_hours'.format(shift_type)) + shift.length) + else: + hours_incomplete = True + empday['hours_incomplete'] = True del employee_shifts[0] else: break - if empday['hours']: - minutes = (empday['hours'].days * 1440) + (empday['hours'].seconds / 60) - empday['hours_display'] = '{}:{:02d}'.format(minutes // 60, minutes % 60) - employee.weekdays.append(empday) + hours = empday['{}_hours'.format(shift_type)] + if hours: + if hours_style == 'pretty': + display = app.render_duration(hours=hours) + else: # decimal + 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) - if employee.hours: - minutes = (employee.hours.days * 1440) + (employee.hours.seconds / 60) - employee.hours_display = '{}:{:02d}'.format(minutes // 60, minutes % 60) + hours = getattr(employee, '{}_hours'.format(shift_type)) + if hours: + if hours_style == 'pretty': + display = app.render_duration(hours=hours) + else: # decimal + display = str(hours_as_decimal(hours)) + if hours_incomplete: + display = '{} ?'.format(display) + setattr(employee, '{}_hours_display'.format(shift_type), display) @classmethod def defaults(cls, config): @@ -370,24 +504,26 @@ class TimeSheetView(View): Provide default configuration for a time sheet view. """ title = cls.get_title() + url_prefix = cls.get_url_prefix() config.add_tailbone_permission_group(cls.key, title) - config.add_tailbone_permission(cls.key, '{}.view'.format(cls.key), "View employee {}".format(title)) config.add_tailbone_permission(cls.key, '{}.viewall'.format(cls.key), "View full {}".format(title)) # full time sheet - config.add_route(cls.key, '/{}/'.format(cls.key)) + config.add_route(cls.key, '{}/'.format(url_prefix)) config.add_view(cls, attr='full', route_name=cls.key, renderer='/shifts/{}.mako'.format(cls.key), permission='{}.viewall'.format(cls.key)) # single employee time sheet - config.add_route('{}.employee'.format(cls.key), '/{}/employee/'.format(cls.key)) - config.add_view(cls, attr='employee', route_name='{}.employee'.format(cls.key), - renderer='/shifts/{}.mako'.format(cls.key), - permission='{}.view'.format(cls.key)) + if cls.expose_employee_views: + config.add_tailbone_permission(cls.key, '{}.view'.format(cls.key), "View single employee {}".format(title)) + config.add_route('{}.employee'.format(cls.key), '{}/employee/'.format(url_prefix)) + config.add_view(cls, attr='employee', route_name='{}.employee'.format(cls.key), + renderer='/shifts/{}.mako'.format(cls.key), + permission='{}.view'.format(cls.key)) # goto cross-view (view 'timesheet' as 'schedule' or vice-versa) other_key = 'timesheet' if cls.key == 'schedule' else 'schedule' - config.add_route('{}.goto.{}'.format(cls.key, other_key), '/{}/goto-{}'.format(cls.key, other_key)) + config.add_route('{}.goto.{}'.format(cls.key, other_key), '{}/goto-{}'.format(url_prefix, other_key)) config.add_view(cls, attr='crossview', route_name='{}.goto.{}'.format(cls.key, other_key), permission='{}.view'.format(other_key)) diff --git a/tailbone/views/shifts/schedule.py b/tailbone/views/shifts/schedule.py index 6229fbd2..c8b82724 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.py @@ -1,33 +1,35 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 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 tailbone.db import Session from tailbone.views.shifts.lib import TimeSheetView @@ -38,6 +40,182 @@ class ScheduleView(TimeSheetView): key = 'schedule' model_class = model.ScheduledShift + def edit(self): + """ + View for editing (full) schedule. + """ + # first check if we should clear the schedule + if self.request.method == 'POST' and self.request.POST.get('clear-schedule') == 'clear': + count = self.clear_schedule() + self.request.session.flash("Removed {} shifts from current schedule.".format(count)) + return self.redirect(self.request.route_url('schedule.edit')) + + # okay then, check if we should copy data from another week + if self.request.method == 'POST' and self.request.POST.get('copy-week'): + sunday, copied = self.copy_schedule() + self.request.session.flash("Copied {} shifts from week of {}".format(copied, sunday.strftime('%m/%d/%Y'))) + return self.redirect(self.request.route_url('schedule.edit')) + + # okay then, process filters; redirect if any were received + 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': + + # organize form data by uuid / field + fields = ['employee_uuid', 'store_uuid', 'start_time', 'end_time', 'delete'] + data = dict([(f, {}) for f in fields]) + for key in self.request.POST: + for field in fields: + if key.startswith('{}-'.format(field)): + uuid = key[len('{}-'.format(field)):] + if uuid: + data[field][uuid] = self.request.POST[key] + break + + # apply delete operations + deleted = [] + for uuid, value in data['delete'].items(): + if value == 'delete': + shift = Session.get(model.ScheduledShift, uuid) + if shift: + Session.delete(shift) + deleted.append(uuid) + + # apply create / update operations + created = {} + updated = {} + time_format = '%a %d %b %Y %I:%M %p' + for uuid, employee_uuid in data['start_time'].items(): + if uuid in deleted: + continue + if uuid.startswith('new-'): + shift = model.ScheduledShift() + shift.employee_uuid = data['employee_uuid'][uuid] + if 'store_uuid' in data and uuid in data['store_uuid']: + shift.store_uuid = data['store_uuid'][uuid] + else: + shift.store_uuid = context['store'].uuid if context['store'] else None + Session.add(shift) + created[uuid] = shift + else: + shift = Session.get(model.ScheduledShift, uuid) + assert shift + updated[uuid] = shift + start_time = datetime.datetime.strptime(data['start_time'][uuid], time_format) + shift.start_time = make_utc(localtime(self.rattail_config, start_time)) + end_time = datetime.datetime.strptime(data['end_time'][uuid], time_format) + shift.end_time = make_utc(localtime(self.rattail_config, end_time)) + + self.request.session.flash("Changes were applied: created {}, updated {}, " + "deleted {} Scheduled Shifts".format( + len(created), len(updated), len(deleted))) + return self.redirect(self.request.route_url('schedule.edit')) + + context['form'] = form + context['page_title'] = "Edit Schedule" + context['allow_clear'] = self.rattail_config.getbool('tailbone', 'schedule.allow_clear', + default=True) + return self.render_full(**context) + + def clear_schedule(self): + deleted = 0 + context = self.get_timesheet_context() + if context['employees']: + sunday = datetime.datetime.combine(context['date'], datetime.time(0)) + start_time = localtime(self.rattail_config, sunday) + end_time = localtime(self.rattail_config, sunday + datetime.timedelta(days=7)) + shifts = Session.query(model.ScheduledShift)\ + .filter(model.ScheduledShift.employee_uuid.in_([e.uuid for e in context['employees']]))\ + .filter(model.ScheduledShift.start_time >= make_utc(start_time))\ + .filter(model.ScheduledShift.end_time < make_utc(end_time)) + for shift in shifts: + Session.delete(shift) + deleted += 1 + return deleted + + def copy_schedule(self): + """ + Clear current schedule, then copy shift data from another week. + """ + try: + sunday = datetime.datetime.strptime(self.request.POST['copy-week'], '%m/%d/%Y').date() + except ValueError as error: + self.request.session.flash("Invalid date specified: {}: {}".format(type(error), error), 'error') + raise self.redirect(self.request.route_url('schedule.edit')) + sunday = get_sunday(sunday) + context = self.get_timesheet_context() + if sunday == context['date']: + self.request.session.flash("Cannot copy schedule from same week; please specify a different week.", 'error') + raise self.redirect(self.request.route_url('schedule.edit')) + + self.clear_schedule() + + copied = 0 + if context['employees']: + offset = context['date'] - sunday + sunday = datetime.datetime.combine(sunday, datetime.time(0)) + start_time = localtime(self.rattail_config, sunday) + end_time = localtime(self.rattail_config, sunday + datetime.timedelta(days=7)) + shifts = Session.query(model.ScheduledShift)\ + .filter(model.ScheduledShift.employee_uuid.in_([e.uuid for e in context['employees']]))\ + .filter(model.ScheduledShift.start_time >= make_utc(start_time))\ + .filter(model.ScheduledShift.end_time < make_utc(end_time)) + for shift in shifts: + + # must calculate new times using date as base, b/c of daylight savings + start_time = localtime(self.rattail_config, shift.start_time, from_utc=True) + start_time = datetime.datetime.combine(start_time.date() + offset, start_time.time()) + start_time = localtime(self.rattail_config, start_time) + end_time = localtime(self.rattail_config, shift.end_time, from_utc=True) + end_time = datetime.datetime.combine(end_time.date() + offset, end_time.time()) + end_time = localtime(self.rattail_config, end_time) + + Session.add(model.ScheduledShift( + employee_uuid=shift.employee_uuid, + store_uuid=shift.store_uuid, + start_time=make_utc(start_time), + end_time=make_utc(end_time), + )) + copied += 1 + + return sunday, copied + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + # edit schedule + config.add_route('schedule.edit', '/schedule/edit') + config.add_view(cls, attr='edit', route_name='schedule.edit', + renderer='/shifts/schedule_edit.mako', + permission='schedule.edit') + config.add_tailbone_permission('schedule', 'schedule.edit', "Edit full 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') + + # 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 54dafcf2..a8874127 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.py @@ -1,44 +1,142 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 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 tailbone.db import Session from tailbone.views.shifts.lib import TimeSheetView as BaseTimeSheetView class TimeSheetView(BaseTimeSheetView): """ - Simple view for current user's time sheet. + Views for employee time sheets, i.e. worked shift data """ key = 'timesheet' title = "Time Sheet" model_class = model.WorkedShift + def edit_employee(self): + """ + View for editing single employee's timesheet + """ + # process filters; redirect if any were received + 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': + + # TODO: most of this is copied from 'schedule.edit' view, should merge... + + # organize form data by uuid / field + fields = ['start_time', 'end_time', 'delete'] + data = dict([(f, {}) for f in fields]) + for key in self.request.POST: + for field in fields: + if key.startswith('{}-'.format(field)): + uuid = key[len('{}-'.format(field)):] + if uuid: + data[field][uuid] = self.request.POST[key] + break + + # apply delete operations + deleted = [] + for uuid, value in list(data['delete'].items()): + assert value == 'delete' + shift = Session.get(model.WorkedShift, uuid) + assert shift + Session.delete(shift) + deleted.append(uuid) + + # apply create / update operations + created = {} + updated = {} + time_format = '%a %d %b %Y %I:%M %p' + for uuid, time in data['start_time'].items(): + if uuid in deleted: + continue + if uuid.startswith('new-'): + shift = model.WorkedShift() + shift.employee_uuid = context['employee'].uuid + # TODO: add support for setting store here... + Session.add(shift) + created[uuid] = shift + else: + shift = Session.get(model.WorkedShift, uuid) + assert shift + updated[uuid] = shift + + start_time = data['start_time'][uuid] or None + if start_time: + start_time = datetime.datetime.strptime(start_time, time_format) + shift.start_time = make_utc(localtime(self.rattail_config, start_time)) + else: + shift.start_time = None + + end_time = data['end_time'][uuid] or None + if end_time: + end_time = datetime.datetime.strptime(end_time, time_format) + shift.end_time = make_utc(localtime(self.rattail_config, end_time)) + else: + shift.end_time = None + + self.request.session.flash("Changes were applied: created {}, updated {}, " + "deleted {} Worked Shifts".format( + len(created), len(updated), len(deleted))) + return self.redirect(self.request.route_url('timesheet.employee.edit')) + + context['form'] = form + context['page_title'] = "Edit Employee Time Sheet" + return self.render_single(**context) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + # edit employee time sheet + config.add_tailbone_permission('timesheet', 'timesheet.edit', + "Edit time sheet (for *any* employee!)") + config.add_route('timesheet.employee.edit', '/timesheeet/employee/edit') + config.add_view(cls, attr='edit_employee', route_name='timesheet.employee.edit', + renderer='/shifts/timesheet_edit.mako', + permission='timesheet.edit') + + +def defaults(config, **kwargs): + base = globals() + + TimeSheetView = kwargs.get('TimeSheetView', base['TimeSheetView']) + TimeSheetView.defaults(config) + def includeme(config): - TimeSheetView.defaults(config) + defaults(config) diff --git a/tailbone/views/stores.py b/tailbone/views/stores.py index 095ba5c4..5d507745 100644 --- a/tailbone/views/stores.py +++ b/tailbone/views/stores.py @@ -1,23 +1,23 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ @@ -30,57 +30,103 @@ import sqlalchemy as sa from rattail.db import model +import colander + +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', + 'name', + 'phone', + 'email', + ] + + form_fields = [ + 'id', + 'name', + 'phone', + 'email', + 'database_key', + 'archived', + ] + + labels = { + 'id': "ID", + 'phone': "Phone Number", + 'email': "Email Address", + } def configure_grid(self, g): + super(StoreView, self).configure_grid(g) - g.joiners['email'] = lambda q: q.outerjoin(model.StoreEmailAddress, sa.and_( + g.set_joiner('email', lambda q: q.outerjoin(model.StoreEmailAddress, sa.and_( model.StoreEmailAddress.parent_uuid == model.Store.uuid, - model.StoreEmailAddress.preference == 1)) - g.joiners['phone'] = lambda q: q.outerjoin(model.StorePhoneNumber, sa.and_( + model.StoreEmailAddress.preference == 1))) + g.set_joiner('phone', lambda q: q.outerjoin(model.StorePhoneNumber, sa.and_( model.StorePhoneNumber.parent_uuid == model.Store.uuid, - model.StorePhoneNumber.preference == 1)) - - g.filters['email'] = g.make_filter('email', model.StoreEmailAddress.address, - label="Email Address") - g.filters['phone'] = g.make_filter('phone', model.StorePhoneNumber.number, - label="Phone Number") + model.StorePhoneNumber.preference == 1))) + 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' - g.filters['id'].label = "ID" - g.sorters['email'] = lambda q, d: q.order_by(getattr(model.StoreEmailAddress.address, d)()) - g.sorters['phone'] = lambda q, d: q.order_by(getattr(model.StorePhoneNumber.number, d)()) + # archived + g.filters['archived'].default_active = True + g.filters['archived'].default_verb = 'is_false_null' - g.default_sortkey = 'id' + g.set_sorter('phone', model.StorePhoneNumber.number) + g.set_sorter('email', model.StoreEmailAddress.address) + g.set_sort_defaults('id') - g.configure( - include=[ - g.id.label("ID"), - g.name, - g.phone.label("Phone Number"), - g.email.label("Email Address"), - ], - readonly=True) + g.set_link('id') + g.set_link('name') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.id.label("ID"), - fs.name, - fs.database_key, - fs.phone.label("Phone Number").readonly(), - fs.email.label("Email Address").readonly(), - ]) + def grid_extra_class(self, store, i): + if store.archived: + return 'warning' + + def configure_form(self, f): + super(StoreView, self).configure_form(f) + + f.remove_field('employees') + f.remove_field('phones') + f.remove_field('emails') + + if self.creating: + f.remove_field('phone') + f.remove_field('email') + else: + f.set_readonly('phone') + f.set_readonly('email') + + def get_version_child_classes(self): + return [ + (model.StorePhoneNumber, 'parent_uuid'), + (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 d5e4f6a1..43648ea6 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -1,73 +1,194 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Subdepartment Views """ -from __future__ import unicode_literals +import sqlalchemy as sa from rattail.db import model +from deform import widget as dfwidget + +from tailbone.db import Session from tailbone.views import MasterView -from tailbone.views.continuum import VersionView, version_defaults -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 = [ + 'number', + 'name', + 'department', + ] + + form_fields = [ + 'number', + 'name', + 'department', + ] + + mergeable = True + merge_additive_fields = [ + 'product_count', + ] + merge_fields = merge_additive_fields + [ + 'uuid', + 'number', + 'name', + '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(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.configure( - include=[ - g.number, - g.name, - g.department, - ], - readonly=True) + g.set_sort_defaults('name') - def configure_fieldset(self, fs): - fs.configure( - include=[ - fs.number, - fs.name, - fs.department, - ]) - return fs + # 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(SubdepartmentView, self).configure_form(f) + f.remove_field('products') + + # 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 { + 'uuid': subdept.uuid, + 'number': subdept.number, + 'name': subdept.name, + 'department_number': subdept.department.number if subdept.department else None, + 'product_count': len(subdept.products), + } + + def merge_objects(self, removing, keeping): + + # merge products + for product in removing.products: + product.subdepartment = keeping + + 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) -class SubdepartmentVersionView(VersionView): - """ - View which shows version history for a subdepartment. - """ - parent_class = model.Subdepartment - route_model_view = 'subdepartments.view' +# 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) - version_defaults(config, SubdepartmentVersionView, 'subdepartment') + defaults(config) diff --git a/tailbone/views/tables.py b/tailbone/views/tables.py new file mode 100644 index 00000000..bfd52f2b --- /dev/null +++ b/tailbone/views/tables.py @@ -0,0 +1,459 @@ +# -*- 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 with info about the underlying Rattail tables +""" + +import os +import sys +import warnings + +import sqlalchemy as sa +from sqlalchemy_utils import get_mapper + +from rattail.util import simple_error + +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML + +from tailbone.views import MasterView + + +class TableView(MasterView): + """ + Master view for tables + """ + normalized_model_name = 'table' + model_key = 'table_name' + model_title = "Table" + creatable = False + editable = False + deletable = False + filterable = False + pageable = False + + labels = { + 'branch_name': "Schema Branch", + 'model_name': "Model Class", + 'module_name': "Module", + 'module_file': "File", + } + + grid_columns = [ + '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 relname, n_live_tup + from pg_stat_user_tables + where schemaname = 'public' + order by n_live_tup desc; + """ + 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): + 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') + + 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): + defaults(config) diff --git a/tailbone/views/taxes.py b/tailbone/views/taxes.py index f29812e8..b2afaeb9 100644 --- a/tailbone/views/taxes.py +++ b/tailbone/views/taxes.py @@ -1,64 +1,87 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Tax Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model from tailbone.views import MasterView -class TaxesView(MasterView): +class TaxView(MasterView): """ Master view for taxes. """ model_class = model.Tax model_title_plural = "Taxes" route_prefix = 'taxes' + has_versions = True + + grid_columns = [ + 'code', + 'description', + 'rate', + ] + + form_fields = [ + 'code', + 'description', + 'rate', + ] def configure_grid(self, 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.configure( - include=[ - g.code, - g.description, - g.rate, - ], - readonly=True) - 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 new file mode 100644 index 00000000..840a38c1 --- /dev/null +++ b/tailbone/views/tempmon/__init__.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2020 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 +""" + +from __future__ import unicode_literals, absolute_import + +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 new file mode 100644 index 00000000..1b2d49d8 --- /dev/null +++ b/tailbone/views/tempmon/clients.py @@ -0,0 +1,294 @@ +# -*- 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 tempmon clients +""" + +import subprocess + +from rattail.config import parse_list +from rattail_tempmon.db import model as tempmon + +import colander +from webhelpers2.html import HTML, tags + +from tailbone import forms +from tailbone.views.tempmon import MasterView +from tailbone.util import raw_datetime + + +class TempmonClientView(MasterView): + """ + Master view for tempmon clients. + """ + model_class = tempmon.Client + model_title = "TempMon Client" + model_title_plural = "TempMon Clients" + route_prefix = 'tempmon.clients' + url_prefix = '/tempmon/clients' + + has_rows = True + model_row_class = tempmon.Reading + rows_title = "Readings" + + grid_columns = [ + 'config_key', + 'hostname', + 'location', + '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().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.set_link('location') + + # disk_type + g.set_enum('disk_type', self.enum.TEMPMON_DISK_TYPE) + + # 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: + 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 + readings = self.Session.query(tempmon.Reading)\ + .filter(tempmon.Reading.client == client) + readings.delete(synchronize_session=False) + self.Session.flush() + self.Session.refresh(client) + + # 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(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): + cmd = self.get_restart_cmd(client) + return bool(cmd) + + def restart(self): + client = self.get_instance() + if self.restartable_client(client): + try: + subprocess.check_output(self.get_restart_cmd(client), + stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as error: + self.request.session.flash("Failed to restart client: {}".format(error.output), 'error') + else: + self.request.session.flash("Client has been restarted: {}".format( + self.get_instance_title(client))) + else: + self.request.session.flash("Restart not supported for client: {}".format(client), 'error') + return self.redirect(self.get_action_url('view', client)) + + def get_restart_cmd(self, client): + 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): + 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) + + # restart tempmon client + config.add_tailbone_permission(permission_prefix, '{}.restart'.format(permission_prefix), + "Restart a {}".format(model_title)) + config.add_route('{}.restart'.format(route_prefix), '{}/{{{}}}/restart'.format(url_prefix, model_key)) + config.add_view(cls, attr='restart', route_name='{}.restart'.format(route_prefix), + permission='{}.restart'.format(permission_prefix)) + + +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 new file mode 100644 index 00000000..7540abbe --- /dev/null +++ b/tailbone/views/tempmon/core.py @@ -0,0 +1,102 @@ +# -*- 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/>. +# +################################################################################ +""" +Common stuff for tempmon views +""" + +from webhelpers2.html import HTML + +from tailbone import views, grids +from tailbone.db import TempmonSession + + +class MasterView(views.MasterView): + """ + Base class for tempmon views. + """ + Session = TempmonSession + + def get_bulk_delete_session(self): + from rattail_tempmon.db import Session + return Session() + + def normalize_probes(self, probes): + data = [] + for probe in probes: + view_url = self.request.route_url('tempmon.probes.view', uuid=probe.uuid) + edit_url = self.request.route_url('tempmon.probes.edit', uuid=probe.uuid) + data.append({ + 'uuid': probe.uuid, + 'url': view_url, + '_action_url_view': view_url, + '_action_url_edit': edit_url, + 'description': probe.description, + 'critical_temp_min': probe.critical_temp_min, + 'good_temp_min': probe.good_temp_min, + 'good_temp_max': probe.good_temp_max, + 'critical_temp_max': probe.critical_temp_max, + 'status': self.enum.TEMPMON_PROBE_STATUS.get(probe.status, '??'), + 'enabled': "Yes" if probe.enabled else "No", + }) + app = self.get_rattail_app() + data = app.json_friendly(data) + return data + + def render_probes(self, obj, field): + """ + This method is used by Appliance and Client views. + """ + if not obj.probes: + return "" + + 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()) + + 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 new file mode 100644 index 00000000..573f9a2d --- /dev/null +++ b/tailbone/views/tempmon/probes.py @@ -0,0 +1,337 @@ +# -*- 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 tempmon probes +""" + +import datetime + +from rattail_tempmon.db import model as tempmon + +import colander +from deform import widget as dfwidget +from webhelpers2.html import tags + +from tailbone import forms, grids +from tailbone.views.tempmon import MasterView +from tailbone.util import raw_datetime + + +class TempmonProbeView(MasterView): + """ + Master view for tempmon probes. + """ + model_class = tempmon.Probe + model_title = "TempMon Probe" + model_title_plural = "TempMon Probes" + 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', + 'enabled', + 'status', + ] + + 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', + ] + + 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_renderer('enabled', self.render_enabled_grid) + + g.set_label('config_key', "Key") + + g.set_link('client') + g.set_link('config_key') + g.set_link('description') + + def render_enabled_grid(self, probe, field): + if probe.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) + + # client + f.set_renderer('client', self.render_client) + f.set_label('client', "Tempmon Client") + if self.creating or self.editing: + 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 + readings = self.Session.query(tempmon.Reading)\ + .filter(tempmon.Reading.probe == probe) + readings.delete(synchronize_session=False) + self.Session.flush() + self.Session.refresh(probe) + + # 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(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): + defaults(config) diff --git a/tailbone/views/tempmon/readings.py b/tailbone/views/tempmon/readings.py new file mode 100644 index 00000000..02e3fc51 --- /dev/null +++ b/tailbone/views/tempmon/readings.py @@ -0,0 +1,137 @@ +# -*- 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 tempmon readings +""" + +from sqlalchemy import orm + +from rattail_tempmon.db import model as tempmon + +from webhelpers2.html import tags + +from tailbone.views.tempmon import MasterView + + +class TempmonReadingView(MasterView): + """ + Master view for tempmon readings. + """ + model_class = tempmon.Reading + model_title = "TempMon Reading" + model_title_plural = "TempMon Readings" + route_prefix = 'tempmon.readings' + url_prefix = '/tempmon/readings' + creatable = False + editable = False + bulk_deletable = True + + grid_columns = [ + 'client_key', + 'client_host', + 'probe', + 'taken', + '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().configure_grid(g) + + # client_key + g.set_sorter('client_key', tempmon.Client.config_key) + g.set_filter('client_key', tempmon.Client.config_key) + + # client_host + g.set_sorter('client_host', tempmon.Client.hostname) + g.set_filter('client_host', tempmon.Client.hostname) + + # 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) + g.set_renderer('client_host', self.render_client_host) + + g.set_link('probe') + g.set_link('taken') + + def render_client_key(self, reading, column): + return reading.client.config_key + + def render_client_host(self, reading, column): + return reading.client.hostname + + def configure_form(self, f): + super().configure_form(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): + defaults(config) diff --git a/tailbone/views/tenders.py b/tailbone/views/tenders.py new file mode 100644 index 00000000..46f51c83 --- /dev/null +++ b/tailbone/views/tenders.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +Views for tenders +""" + +from rattail.db.model import Tender + +from tailbone.views import MasterView + + +class TenderView(MasterView): + """ + Master view for the Tender class. + """ + model_class = Tender + has_versions = True + + grid_columns = [ + 'code', + 'name', + 'is_cash', + 'is_foodstamp', + 'allow_cash_back', + 'kick_drawer', + ] + + form_fields = [ + 'code', + 'name', + 'is_cash', + 'is_foodstamp', + 'allow_cash_back', + 'kick_drawer', + 'notes', + 'disabled', + ] + + def configure_grid(self, g): + super().configure_grid(g) + + g.set_link('code') + + g.set_link('name') + g.set_sort_defaults('name') + + def grid_extra_class(self, tender, i): + if tender.disabled: + return 'warning' + + def configure_form(self, f): + super().configure_form(f) + + f.set_type('notes', 'text') + + +def defaults(config, **kwargs): + base = globals() + + TenderView = kwargs.get('TenderView', base['TenderView']) + TenderView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/views/trainwreck/__init__.py b/tailbone/views/trainwreck/__init__.py new file mode 100644 index 00000000..b5eea351 --- /dev/null +++ b/tailbone/views/trainwreck/__init__.py @@ -0,0 +1,33 @@ +# -*- 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/>. +# +################################################################################ +""" +Trainwreck Views +""" + +from __future__ import unicode_literals, absolute_import + +from .base import TransactionView + + +def includeme(config): + config.include('tailbone.views.trainwreck.defaults') diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py 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 new file mode 100644 index 00000000..ffa88032 --- /dev/null +++ b/tailbone/views/upgrades.py @@ -0,0 +1,600 @@ +# -*- 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 app upgrades +""" + +import json +import os +import re +import logging +import warnings +from collections import OrderedDict + +import sqlalchemy as sa + +from rattail.db.model import Upgrade +from rattail.threads import Thread + +from deform import widget as dfwidget +from webhelpers2.html import tags, HTML + +from tailbone.views import MasterView +from tailbone.progress import get_progress_session #, SessionProgress +from tailbone.config import should_expose_websockets + + +log = logging.getLogger(__name__) + + +class UpgradeView(MasterView): + """ + Master view for all user events + """ + model_class = Upgrade + downloadable = True + cloneable = True + configurable = True + executable = True + execute_progress_template = '/upgrade.mako' + execute_progress_initial_msg = "Upgrading" + execute_can_cancel = False + + labels = { + 'executed_by': "Executed by", + 'status_code': "Status", + 'stdout_file': "STDOUT", + 'stderr_file': "STDERR", + } + + grid_columns = [ + 'system', + 'created', + 'description', + # 'not_until', + 'enabled', + 'status_code', + 'executed', + 'executed_by', + ] + + form_fields = [ + 'system', + 'description', + # 'not_until', + # 'requirements', + 'notes', + 'created', + 'created_by', + 'enabled', + 'executing', + 'executed', + 'executed_by', + 'status_code', + 'stdout_file', + 'stderr_file', + 'exit_code', + 'package_diff', + ] + + def __init__(self, request): + super().__init__(request) + + 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() + + else: + app = self.get_rattail_app() + self.upgrade_handler = app.get_upgrade_handler() + + @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().configure_grid(g) + model = self.model + + # system + systems = self.upgrade_handler.get_all_systems() + systems_enum = dict([(s['key'], s['label']) for s in systems]) + g.set_enum('system', systems_enum) + + g.set_joiner('executed_by', lambda q: q.join(model.User, model.User.uuid == model.Upgrade.executed_by_uuid).outerjoin(model.Person)) + g.set_sorter('executed_by', model.Person.display_name) + g.set_enum('status_code', self.enum.UPGRADE_STATUS) + g.set_type('created', 'datetime') + g.set_type('executed', 'datetime') + g.set_sort_defaults('created', 'desc') + + g.set_link('system') + g.set_link('created') + g.set_link('description') + # g.set_link('not_until') + g.set_link('executed') + + def grid_extra_class(self, upgrade, i): + if upgrade.status_code == self.enum.UPGRADE_STATUS_FAILED: + return 'warning' + if upgrade.status_code == self.enum.UPGRADE_STATUS_EXECUTING: + 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) + 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().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('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) + + # 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') + 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 or not upgrade.executed: + f.remove_field('executed') + f.remove_field('executed_by') + + 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('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 = ['system', 'description', 'notes', 'enabled'] + + def clone_instance(self, original): + app = self.get_rattail_app() + cloned = self.model_class() + cloned.system = original.system + cloned.created = app.make_utc() + cloned.created_by = self.request.user + cloned.description = original.description + cloned.notes = original.notes + cloned.status_code = self.enum.UPGRADE_STATUS_PENDING + cloned.enabled = original.enabled + self.Session.add(cloned) + self.Session.flush() + return cloned + + def render_stdout_file(self, upgrade, fieldname): + if fieldname.startswith('stderr'): + filename = 'stderr.log' + else: + filename = 'stdout.log' + path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename) + if path: + url = '{}?filename={}'.format(self.get_action_url('download', upgrade), filename) + return self.render_file_field(path, url, filename=filename) + return filename + + def render_package_diff(self, upgrade, fieldname): + 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, + **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 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_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', + } + + 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): + return project['commit_url'].format(new_version=new_version, old_version=old_version) + + elif re.match(r'^\d+\.\d+\.\d+$', new_version): + 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) + new_version = diff.new_value(field) + url = self.get_changelog_url(field, old_version, new_version) + if url: + return self.changelog_link(field, url) + return field + + def render_diff_value(self, field, value): + if value is None: + return "" + if value.startswith("u'") and value.endswith("'"): + return value[2:1] + return value + + def parse_requirements(self, upgrade, type_): + packages = {} + path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='requirements.{}.txt'.format(type_)) + 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 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) + + def download_content_type(self, path, filename): + return 'text/plain' + + def before_create_flush(self, upgrade, form): + upgrade.created_by = self.request.user + upgrade.status_code = self.enum.UPGRADE_STATUS_PENDING + + # TODO: this was an attempt to make the progress bar survive Apache restart, + # but it didn't work... need to "fork" instead of waiting for execution? + # def make_execute_progress(self): + # key = '{}.execute'.format(self.get_grid_key()) + # return SessionProgress(self.request, key, session_type='file') + + 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() + + # 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() + key = '{}.execute'.format(self.get_grid_key()) + session = get_progress_session(self.request, key) + if session.get('complete'): + msg = session.get('success_msg') + if msg: + self.request.session.flash(msg) + elif session.get('error'): + self.request.session.flash(session.get('error_msg', "An unspecified error occurred."), 'error') + data = dict(session) + + path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='stdout.log') + offset = session.get('stdout.offset', 0) + if os.path.exists(path): + size = os.path.getsize(path) - offset + 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().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() + 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(instance_url_prefix)) + config.add_view(cls, attr='execute_progress', + route_name='{}.execute_progress'.format(route_prefix), + permission='{}.execute'.format(permission_prefix), + renderer='json') + + # 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): + defaults(config) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 650420a9..dfed0a11 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -1,197 +1,808 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 __future__ import unicode_literals, absolute_import - +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 +from rattail.db.model import User, UserEvent -import formalchemy -from formalchemy.fields import SelectFieldRenderer -from webhelpers.html import HTML, tags +import colander +from deform import widget as dfwidget +from webhelpers2.html import HTML, tags from tailbone import forms -from tailbone.db import Session -from tailbone.views import MasterView -from tailbone.views.continuum import VersionView, version_defaults +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(MasterView): +class UserView(PrincipalMasterView): """ Master view for the User model. """ - model_class = model.User + model_class = User + has_versions = True + touchable = True + mergeable = True + + 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)\ - .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): - g.joiners['person'] = lambda q: q.outerjoin(model.Person) + 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' g.filters['active'].default_active = True g.filters['active'].default_verb = 'is_true' - g.filters['person'] = g.make_filter('person', model.Person.display_name, label="Person's Name", + 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']) - g.sorters['person'] = lambda q, d: q.order_by(getattr(model.Person.display_name, d)()) - g.default_sortkey = 'username' + # password + g.set_filter('password', model.User.password, + verbs=['is_null', 'is_not_null']) - g.person.set(label="Person's Name") - g.configure( - include=[ - g.username, - g.person, - ], - readonly=True) + 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.set_sort_defaults('username') + + g.set_label('person', "Person's Name") + + g.set_link('username') + g.set_link('person') + g.set_link('first_name') + g.set_link('last_name') + g.set_link('display_name') + + def grid_extra_class(self, user, i): + if not user.active: + return 'warning' + + def editable_instance(self, user): + """ + If the given user is "protected" then we only allow edit if current + 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 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') - def configure_fieldset(self, fs): - fs.username.set(validate=unique_username) - fs.person.set(options=[]) - fs.person.set(renderer=forms.renderers.PersonFieldLinkRenderer) - fs.append(PasswordField('password', label="Set Password")) - fs.password.attrs(autocomplete='off') - fs.append(formalchemy.Field('confirm_password', renderer=PasswordFieldRenderer)) - fs.confirm_password.attrs(autocomplete='off') - fs.append(RolesField('roles', renderer=RolesFieldRenderer(self.request))) - fs.configure( - include=[ - fs.username, - fs.person, - fs.active, - fs.password, - fs.confirm_password, - fs.roles, - ]) if self.viewing: - del fs.password - del fs.confirm_password - 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)) + 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().configure_row_grid(g) + g.width = 'half' + g.filterable = False + g.set_sort_defaults('occurred', 'desc') + g.set_enum('type_code', self.enum.USER_EVENT) + g.set_label('type_code', "Event Type") + + 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)\ + .order_by(model.User.username)\ + .options(orm.joinedload(model.User._roles)\ + .joinedload(model.UserRole.role)\ + .joinedload(model.Role._permissions)) + users = [] + for user in all_users: + if auth.has_permission(session, user, permission): + users.append(user) + return users + + def find_by_perm_configure_results_grid(self, g): + g.append('username') + g.set_link('username') + + g.append('person') + g.set_link('person') + + def find_by_perm_normalize(self, user): + data = super().find_by_perm_normalize(user) + + data['username'] = user.username + data['person'] = str(user.person or '') + + return data + + def preferences(self, user=None): + """ + View to modify preferences for a particular user. + """ + 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 UserVersionView(VersionView): +# TODO: deprecate / remove this +UsersView = UserView + + +class UserEventView(MasterView): """ - View which shows version history for a user. + Master view for all user events """ - parent_class = model.User - route_model_view = 'users.view' + model_class = UserEvent + url_prefix = '/user-events' + viewable = False + creatable = False + editable = False + deletable = False + + grid_columns = [ + 'user', + 'person', + 'type_code', + 'occurred', + ] + + def get_data(self, session=None): + query = super().get_data(session=session) + model = self.model + return query.join(model.User) + + def configure_grid(self, 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) + g.filters['user'] = g.make_filter('user', model.User.username) + g.filters['person'] = g.make_filter('person', model.Person.display_name) + g.set_enum('type_code', self.enum.USER_EVENT) + g.set_type('occurred', 'datetime') + g.set_renderer('user', self.render_user) + g.set_renderer('person', self.render_person) + g.set_sort_defaults('occurred', 'desc') + g.set_label('user', "Username") + g.set_label('type_code', "Event Type") + + def render_user(self, event, column): + return event.user.username + + def render_person(self, event, column): + 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) - version_defaults(config, UserVersionView, 'user') + 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 177fb39f..210df39e 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -1,35 +1,37 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ Views pertaining to vendors """ -from __future__ import unicode_literals, absolute_import +from .core import VendorView -from .core import VendorsView, VendorVersionView, 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 cc04d067..2471ad47 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -1,160 +1,36 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 +(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.db.batch.vendorcatalog.handler import VendorCatalogHandler -from rattail.vendors.catalogs import iter_catalog_parsers - -import formalchemy - -from tailbone import forms -from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView - - -log = logging.getLogger(__name__) - - -class VendorCatalogsView(FileBatchMasterView): - """ - Master view for vendor catalog batches. - """ - model_class = model.VendorCatalog - model_row_class = model.VendorCatalogRow - batch_handler_class = VendorCatalogHandler - url_prefix = '/vendors/catalogs' - - 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): - 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) - - g.configure( - include=[ - g.created, - g.created_by, - g.vendor, - g.effective, - g.filename, - g.executed, - ], - readonly=True) - - 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 configure_row_grid(self, g): - g.configure( - include=[ - g.sequence, - g.upc.label("UPC"), - g.brand_name.label("Brand"), - g.description, - g.size, - g.vendor_code, - g.old_unit_cost.label("Old Cost"), - g.unit_cost.label("New Cost"), - g.unit_cost_diff.label("Diff."), - g.status_code, - ], - readonly=True) - - def row_grid_row_attrs(self, row, i): - attrs = {} - if row.status_code in (row.STATUS_NEW_COST, row.STATUS_UPDATE_COST): - attrs['class_'] = 'notice' - if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: - attrs['class_'] = 'warning' - return attrs - - 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 - - @classmethod - def defaults(cls, config): - - # fix permission group title - config.add_tailbone_permission_group('vendorcatalogs', "Vendor Catalogs") - - cls._filebatch_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) +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 2aa46745..addf153c 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -1,104 +1,249 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2015 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. # ################################################################################ """ 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 MasterView, AutocompleteView -from tailbone.views.continuum import VersionView, version_defaults -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().configure_grid(g) + g.filters['name'].default_active = True g.filters['name'].default_verb = 'contains' - g.filters['id'].label = "ID" - g.default_sortkey = 'name' + g.set_sort_defaults('name') - g.append(forms.AssociationProxyField('contact')) - g.configure( - include=[ - g.id.label("ID"), - g.name, - g.phone.label("Phone Number"), - g.email.label("Email Address"), - g.contact, - ], - readonly=True) + g.set_label('phone', "Phone Number") + g.set_label('email', "Email Address") - 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(), - ]) + g.set_link('id') + g.set_link('name') + g.set_link('abbreviation') + + 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 super().get_version_child_classes() + [ + (model.VendorPhoneNumber, 'parent_uuid'), + (model.VendorEmailAddress, 'parent_uuid'), + (model.VendorContact, 'vendor_uuid'), + ] + + def configure_get_simple_settings(self): + config = self.rattail_config + return [ + + # display + {'section': 'rattail', + 'option': 'vendors.choice_uses_dropdown', + 'type': bool}, + ] + + def configure_get_context(self, **kwargs): + context = super().configure_get_context(**kwargs) + + context['supported_vendor_settings'] = self.configure_get_supported_vendor_settings() + + return context + + def configure_gather_settings(self, data, **kwargs): + settings = super().configure_gather_settings( + data, **kwargs) + + supported_vendor_settings = self.configure_get_supported_vendor_settings() + for setting in supported_vendor_settings.values(): + name = 'rattail.vendor.{}'.format(setting['key']) + settings.append({'name': name, + 'value': data[name]}) + + return settings + + def configure_remove_settings(self, **kwargs): + super().configure_remove_settings(**kwargs) + app = self.get_rattail_app() + names = [] + + supported_vendor_settings = self.configure_get_supported_vendor_settings() + for setting in supported_vendor_settings.values(): + names.append('rattail.vendor.{}'.format(setting['key'])) + + if names: + # nb. using thread-local session here; we do not use + # self.Session b/c it may not point to Rattail + session = Session() + for name in names: + app.delete_setting(session, name) + + def configure_get_supported_vendor_settings(self): + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + batch_handler = app.get_batch_handler('purchase') + settings = {} + + for parser in batch_handler.get_supported_invoice_parsers(): + key = parser.vendor_key + if not key: + continue + + vendor = vendor_handler.get_vendor(self.Session(), key) + settings[key] = { + 'key': key, + 'value': vendor.uuid if vendor else None, + 'label': str(vendor) if vendor else None, + } + + return settings -class VendorVersionView(VersionView): - """ - View which shows version history for a vendor. - """ - parent_class = model.Vendor - route_model_view = 'vendors.view' +def defaults(config, **kwargs): + base = globals() - -class VendorsAutocomplete(AutocompleteView): - - mapped_class = model.Vendor - fieldname = 'name' + 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) - version_defaults(config, VendorVersionView, 'vendor') + defaults(config) diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index 4883fb41..40fe0365 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -1,162 +1,36 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# 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 Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. +# 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 Affero General Public License for -# more details. +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. # -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see <http://www.gnu.org/licenses/>. +# 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 +(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.db.batch.vendorinvoice.handler import VendorInvoiceHandler -from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser - -import formalchemy - -from tailbone.db import Session -from tailbone.views.batch import FileBatchMasterView - - -class VendorInvoicesView(FileBatchMasterView): - """ - Master view for vendor invoice batches. - """ - model_class = model.VendorInvoice - model_row_class = model.VendorInvoiceRow - batch_handler_class = VendorInvoiceHandler - url_prefix = '/vendors/invoices' - - def get_instance_title(self, batch): - return unicode(batch.vendor) - - def configure_grid(self, 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) - g.configure( - include=[ - g.created, - g.created_by, - g.vendor, - g.filename, - g.executed, - ], - readonly=True) - - 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 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): - g.filters['upc'].label = "UPC" - g.filters['brand_name'].label = "Brand" - g.configure( - include=[ - g.sequence, - g.upc.label("UPC"), - g.brand_name.label("Brand"), - g.description, - g.size, - g.vendor_code, - g.shipped_cases.label("Cases"), - g.shipped_units.label("Units"), - g.unit_cost, - g.status_code, - ], - readonly=True) - - def row_grid_row_attrs(self, row, i): - attrs = {} - if row.status_code in (row.STATUS_NOT_IN_PURCHASE, - row.STATUS_NOT_IN_INVOICE, - row.STATUS_DIFFERS_FROM_PURCHASE): - attrs['class_'] = 'notice' - if row.status_code in (row.STATUS_NOT_IN_DB, - row.STATUS_COST_NOT_IN_DB): - attrs['class_'] = 'warning' - return attrs - - @classmethod - def defaults(cls, config): - - # fix permission group title - config.add_tailbone_permission_group('vendorinvoices', "Vendor Invoices") - - cls._filebatch_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) +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/data/tailbone.conf b/tests/data/tailbone.conf index 4bdf2b74..7308e6eb 100644 --- a/tests/data/tailbone.conf +++ b/tests/data/tailbone.conf @@ -1,6 +1,6 @@ [app:main] -edbob.config = %(here)s/tailbone.conf +rattail.config = %(__file__)s [rattail.db] keys = default, store 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 33962fd0..f49f6b13 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,18 +1,13 @@ -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals +# -*- 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({'edbob.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 9fcbd7c7..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] - nosetests {posargs} +deps = rattail-tempmon +extras = tests +commands = pytest {posargs} [testenv:coverage] -basepython = python -commands = - pip install --upgrade Tailbone rattail[bouncer] - 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] - 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