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 `` 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 `` 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 `` 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 ```` + +- fix "view receiving row" page, per oruga + +- tweak styles for grid action links, per butterball + +### Fix + +- fix employees grid when viewing department (per oruga) + +- fix login "enter" key behavior, per oruga + +- fix button text for autocomplete + +## v0.10.1 (2024-04-28) + +### Feat + +- sort list of available themes + +- update various icon names for oruga compatibility + +- show "View This" button when cloning a record + +- stop including 'falafel' as available theme + +### Fix + +- fix vertical alignment in main menu bar, for butterball + +- fix upgrade execution logic/UI per oruga + +## v0.10.0 (2024-04-28) + +This version bump is to reflect adding support for Vue 3 + Oruga via +the 'butterball' theme. There is likely more work to be done for that +yet, but it mostly works at this point. + +### Feat + +- misc. template and view logic tweaks (applicable to all themes) for + better patterns, consistency etc. + +- add initial support for Vue 3 + Oruga, via "butterball" theme + + +## Older Releases + +Please see `docs/OLDCHANGES.rst` for older release notes. diff --git a/README.rst b/README.md similarity index 56% rename from README.rst rename to README.md index 0cffc62d..74c007f6 100644 --- a/README.rst +++ b/README.md @@ -1,10 +1,8 @@ -Tailbone -======== +# Tailbone Tailbone is an extensible web application based on Rattail. It provides a "back-office network environment" (BONE) for use in managing retail data. -Please see Rattail's `home page`_ for more information. - -.. _home page: http://rattailproject.org/ +Please see Rattail's [home page](http://rattailproject.org/) for more +information. diff --git a/CHANGES.rst b/docs/OLDCHANGES.rst similarity index 85% rename from CHANGES.rst rename to docs/OLDCHANGES.rst index 11b3a793..0a802f40 100644 --- a/CHANGES.rst +++ b/docs/OLDCHANGES.rst @@ -2,6 +2,1161 @@ 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 ```` 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) -------------------- @@ -3837,7 +4992,7 @@ and related technologies. 0.6.47 (2017-11-08) ------------------- -* Fix manifest to include *.pt deform templates +* Fix manifest to include ``*.pt`` deform templates 0.6.46 (2017-11-08) @@ -4170,13 +5325,13 @@ and related technologies. 0.6.13 (2017-07-26) ------------------- +------------------- * Allow master view to decide whether each grid checkbox is checked 0.6.12 (2017-07-26) ------------------- +------------------- * Add basic support for product inventory and status @@ -4184,7 +5339,7 @@ and related technologies. 0.6.11 (2017-07-18) ------------------- +------------------- * Tweak some basic styles for forms/grids @@ -4192,7 +5347,7 @@ and related technologies. 0.6.10 (2017-07-18) ------------------- +------------------- * Fix grid bug if "current page" becomes invalid diff --git a/docs/api/db.rst b/docs/api/db.rst new file mode 100644 index 00000000..ace21b68 --- /dev/null +++ b/docs/api/db.rst @@ -0,0 +1,6 @@ + +``tailbone.db`` +=============== + +.. automodule:: tailbone.db + :members: diff --git a/docs/api/diffs.rst b/docs/api/diffs.rst new file mode 100644 index 00000000..fb1bba71 --- /dev/null +++ b/docs/api/diffs.rst @@ -0,0 +1,6 @@ + +``tailbone.diffs`` +================== + +.. automodule:: tailbone.diffs + :members: diff --git a/docs/api/forms.widgets.rst b/docs/api/forms.widgets.rst new file mode 100644 index 00000000..33316903 --- /dev/null +++ b/docs/api/forms.widgets.rst @@ -0,0 +1,6 @@ + +``tailbone.forms.widgets`` +========================== + +.. automodule:: tailbone.forms.widgets + :members: diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst index 8b25c994..d28a1b15 100644 --- a/docs/api/subscribers.rst +++ b/docs/api/subscribers.rst @@ -3,5 +3,4 @@ ======================== .. automodule:: tailbone.subscribers - -.. autofunction:: new_request + :members: diff --git a/docs/api/util.rst b/docs/api/util.rst new file mode 100644 index 00000000..35e66ed3 --- /dev/null +++ b/docs/api/util.rst @@ -0,0 +1,6 @@ + +``tailbone.util`` +================= + +.. automodule:: tailbone.util + :members: diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst index bf505b6c..e7de7170 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -81,6 +81,12 @@ override when defining your subclass. override this for certain views, if so that should be done within :meth:`get_help_url()`. + .. attribute:: MasterView.version_diff_factory + + Optional factory to use for version diff objects. By default + this is *not set* but a subclass is free to set it. See also + :meth:`get_version_diff_factory()`. + Methods to Override ------------------- @@ -88,6 +94,8 @@ Methods to Override The following is a list of methods which you can override when defining your subclass. + .. automethod:: MasterView.editable_instance + .. .. automethod:: MasterView.get_settings .. automethod:: MasterView.get_csv_fields @@ -95,3 +103,24 @@ subclass. .. automethod:: MasterView.get_csv_row .. automethod:: MasterView.get_help_url + + .. automethod:: MasterView.get_model_key + + .. automethod:: MasterView.get_version_diff_enums + + .. automethod:: MasterView.get_version_diff_factory + + .. automethod:: MasterView.make_version_diff + + .. automethod:: MasterView.title_for_version + + +Support Methods +--------------- + +The following is a list of methods you should (probably) not need to +override, but may find useful: + + .. automethod:: MasterView.default_edit_url + + .. automethod:: MasterView.get_action_route_kwargs diff --git a/docs/api/views/members.rst b/docs/api/views/members.rst new file mode 100644 index 00000000..6a9e9168 --- /dev/null +++ b/docs/api/views/members.rst @@ -0,0 +1,6 @@ + +``tailbone.views.members`` +========================== + +.. automodule:: tailbone.views.members + :members: diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 00000000..bbf94f4b --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,8 @@ + +Changelog Archive +================= + +.. toctree:: + :maxdepth: 1 + + OLDCHANGES diff --git a/docs/conf.py b/docs/conf.py index 505396ed..ade4c92a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,38 +1,21 @@ -# -*- coding: utf-8; -*- +# Configuration file for the Sphinx documentation builder. # -# Tailbone documentation build configuration file, created by -# sphinx-quickstart on Sat Feb 15 23:15:27 2014. -# -# This file is exec()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html -import sys -import os +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -import sphinx_rtd_theme +from importlib.metadata import version as get_version -exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read()) +project = 'Tailbone' +copyright = '2010 - 2024, Lance Edgar' +author = 'Lance Edgar' +release = get_version('Tailbone') +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.todo', @@ -40,241 +23,30 @@ extensions = [ 'sphinx.ext.viewcode', ] +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + intersphinx_mapping = { - 'rattail': ('https://rattailproject.org/docs/rattail/', None), + 'rattail': ('https://docs.wuttaproject.org/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), + 'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None), + 'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None), } -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'Tailbone' -copyright = u'2010 - 2020, Lance Edgar' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -# version = '0.3' -version = '.'.join(__version__.split('.')[:2]) -# The full version, including alpha/beta/rc tags. -release = __version__ - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# Allow todo entries to show up. +# allow todo entries to show up todo_include_todos = True -# -- Options for HTML output ---------------------------------------------- +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# html_theme = 'classic' -html_theme = 'sphinx_rtd_theme' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +html_theme = 'furo' +html_static_path = ['_static'] # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None -html_logo = 'images/rattail_avatar.png' - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +#html_logo = 'images/rattail_avatar.png' # Output file base name for HTML help builder. -htmlhelp_basename = 'Tailbonedoc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ('index', 'Tailbone.tex', u'Tailbone Documentation', - u'Lance Edgar', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'tailbone', u'Tailbone Documentation', - [u'Lance Edgar'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'Tailbone', u'Tailbone Documentation', - u'Lance Edgar', 'Tailbone', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +#htmlhelp_basename = 'Tailbonedoc' diff --git a/docs/index.rst b/docs/index.rst index b19d859f..d964086f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,19 +44,32 @@ Package API: api/api/batch/core api/api/batch/ordering + api/db + api/diffs api/forms + api/forms.widgets api/grids api/grids.core api/progress api/subscribers + api/util api/views/batch api/views/batch.vendorcatalog api/views/core api/views/master + api/views/members api/views/purchasing.batch api/views/purchasing.ordering +Changelog: + +.. toctree:: + :maxdepth: 1 + + changelog + + Documentation To-Do =================== diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a7214a8e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,103 @@ + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "Tailbone" +version = "0.22.7" +description = "Backoffice Web Application for Rattail" +readme = "README.md" +authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] +license = {text = "GNU GPL v3+"} +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Pyramid", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Office/Business", + "Topic :: Software Development :: Libraries :: Python Modules", +] +requires-python = ">= 3.8" +dependencies = [ + "asgiref", + "colander", + "ColanderAlchemy", + "cornice", + "cornice-swagger", + "deform", + "humanize", + "Mako", + "markdown", + "openpyxl", + "paginate", + "paginate_sqlalchemy", + "passlib", + "Pillow", + "pyramid>=2", + "pyramid_beaker", + "pyramid_deform", + "pyramid_exclog", + "pyramid_fanstatic", + "pyramid_mako", + "pyramid_retry", + "pyramid_tm", + "rattail[db,bouncer]>=0.20.1", + "sa-filters", + "simplejson", + "transaction", + "waitress", + "WebHelpers2", + "WuttaWeb>=0.21.0", + "zope.sqlalchemy>=1.5", +] + + +[project.optional-dependencies] +docs = ["Sphinx", "furo"] +tests = ["coverage", "mock", "pytest", "pytest-cov"] + + +[project.entry-points."paste.app_factory"] +main = "tailbone.app:main" +webapi = "tailbone.webapi:main" + + +[project.entry-points."rattail.cleaners"] +beaker = "tailbone.cleanup:BeakerCleaner" + + +[project.entry-points."rattail.config.extensions"] +tailbone = "tailbone.config:ConfigExtension" + + +[project.urls] +Homepage = "https://rattailproject.org" +Repository = "https://forgejo.wuttaproject.org/rattail/tailbone" +Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues" +Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md" + + +[tool.commitizen] +version_provider = "pep621" +tag_format = "v$version" +update_changelog_on_bump = true + + +[tool.nosetests] +nocapture = 1 +cover-package = "tailbone" +cover-erase = 1 +cover-html = 1 +cover-html-dir = "htmlcov" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7712ec72..00000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[nosetests] -nocapture = 1 -cover-package = tailbone -cover-erase = 1 -cover-html = 1 -cover-html-dir = htmlcov diff --git a/setup.py b/setup.py deleted file mode 100644 index 3328785e..00000000 --- a/setup.py +++ /dev/null @@ -1,190 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see . -# -################################################################################ -""" -Setup script for Tailbone -""" - -from __future__ import unicode_literals, absolute_import - -import os.path -from setuptools import setup, find_packages - - -here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'tailbone', '_version.py')).read()) -README = open(os.path.join(here, 'README.rst')).read() - - -requires = [ - # - # Version numbers within comments below have specific meanings. - # Basically the 'low' value is a "soft low," and 'high' a "soft high." - # In other words: - # - # If either a 'low' or 'high' value exists, the primary point to be - # made about the value is that it represents the most current (stable) - # version available for the package (assuming typical public access - # methods) whenever this project was started and/or documented. - # Therefore: - # - # If a 'low' version is present, you should know that attempts to use - # versions of the package significantly older than the 'low' version - # may not yield happy results. (A "hard" high limit may or may not be - # indicated by a true version requirement.) - # - # Similarly, if a 'high' version is present, and especially if this - # project has laid dormant for a while, you may need to refactor a bit - # when attempting to support a more recent version of the package. (A - # "hard" low limit should be indicated by a true version requirement - # when a 'high' version is present.) - # - # In any case, developers and other users are encouraged to play - # outside the lines with regard to these soft limits. If bugs are - # encountered then they should be filed as such. - # - # package # low high - - # TODO: previously was capping this to pre-1.0 although i'm not sure why. - # however the 1.2 release has some breaking changes which require refactor. - # cf. https://pypi.org/project/zope.sqlalchemy/#id3 - 'zope.sqlalchemy<1.2', # 0.7 1.1 - - # TODO: apparently they jumped from 0.1 to 0.9 and that broke us... - # (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27) - # (i've cached 0.1 at pypi.rattailproject.org just in case it disappears) - # (still, probably a better idea is to refactor so we can use 0.9) - 'webhelpers2_grid==0.1', # 0.1 - - # TODO: remove version cap once we can drop support for python 2.x - 'cornice<5.0', # 3.4.2 4.0.1 - - # TODO: remove once their bug is fixed? idk what this is about yet... - 'deform<2.0.15', # 2.0.14 - - # TODO: cornice<5 requires pyramid<2 (see above) - 'pyramid<2', # 1.3b2 1.10.8 - - 'asgiref', # 3.2.3 - 'colander', # 1.7.0 - 'ColanderAlchemy', # 0.3.3 - 'humanize', # 0.5.1 - 'Mako', # 0.6.2 - 'markdown', # 3.3.3 - 'openpyxl', # 2.4.7 - 'paginate', # 0.5.6 - 'paginate_sqlalchemy', # 0.2.0 - 'passlib', # 1.7.1 - 'Pillow', # 5.3.0 - 'pyramid_beaker>=0.6', # 0.6.1 - 'pyramid_deform', # 0.2 - 'pyramid_exclog', # 0.6 - 'pyramid_mako', # 1.0.2 - 'pyramid_tm', # 0.3 - 'rattail[db,bouncer]', # 0.5.0 - 'six', # 1.10.0 - 'sqlalchemy-filters', # 0.8.0 - 'transaction', # 1.2.0 - 'waitress', # 0.8.1 - 'WebHelpers2', # 2.0 - 'WTForms', # 2.1 -] - - -extras = { - - 'docs': [ - # - # package # low high - - # TODO: remove version workaround after next sphinx[-rtd-theme] release - # cf. https://github.com/readthedocs/sphinx_rtd_theme/issues/1343 - 'Sphinx!=5.2.0.post0', # 1.2 - 'sphinx-rtd-theme', # 0.2.4 - ], - - 'tests': [ - # - # package # low high - - 'coverage', # 3.6 - 'fixture', # 1.5 - 'mock', # 1.0.1 - 'nose', # 1.3.0 - 'pytest', # 4.6.11 - 'pytest-cov', # 2.12.1 - ], -} - - -setup( - name = "Tailbone", - version = __version__, - author = "Lance Edgar", - author_email = "lance@edbob.org", - url = "http://rattailproject.org/", - license = "GNU GPL v3", - description = "Backoffice Web Application for Rattail", - long_description = README, - - classifiers = [ - 'Development Status :: 4 - Beta', - 'Environment :: Web Environment', - 'Framework :: Pyramid', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Office/Business', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - - install_requires = requires, - extras_require = extras, - tests_require = ['Tailbone[tests]'], - test_suite = 'nose.collector', - - packages = find_packages(exclude=['tests.*', 'tests']), - include_package_data = True, - zip_safe = False, - - entry_points = { - - 'paste.app_factory': [ - 'main = tailbone.app:main', - 'webapi = tailbone.webapi:main', - ], - - 'rattail.config.extensions': [ - 'tailbone = tailbone.config:ConfigExtension', - ], - - 'pyramid.scaffold': [ - 'rattail = tailbone.scaffolds:RattailTemplate', - ], - }, -) diff --git a/tailbone/_version.py b/tailbone/_version.py index 358b32de..7095f6c8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,9 @@ # -*- coding: utf-8; -*- -__version__ = '0.8.268' +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + + +__version__ = version('Tailbone') diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 867c15a8..a710e30d 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Auth Views """ -from __future__ import unicode_literals, absolute_import - -from rattail.db.auth import set_user_password - from cornice import Service from tailbone.api import APIView, api @@ -44,11 +40,10 @@ class AuthenticationView(APIView): This will establish a server-side web session for the user if none exists. Note that this also resets the user's session timer. """ - data = {'ok': True} + data = {'ok': True, 'permissions': []} if self.request.user: data['user'] = self.get_user_info(self.request.user) - - data['permissions'] = list(self.request.tailbone_cached_permissions) + data['permissions'] = list(self.request.user_permissions) # background color may be set per-request, by some apps if hasattr(self.request, 'background_color') and self.request.background_color: @@ -168,6 +163,9 @@ class AuthenticationView(APIView): if not self.request.user: raise self.forbidden() + if self.request.user.prevent_password_change and not self.request.is_root: + raise self.forbidden() + data = self.request.json_body # first make sure "current" password is accurate @@ -175,7 +173,8 @@ class AuthenticationView(APIView): return {'error': "The current/old password you provided is incorrect"} # okay then, set new password - set_user_password(self.request.user, data['new_password']) + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, data['new_password']) return { 'ok': True, 'user': self.get_user_info(self.request.user), diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index 5b6102ed..f7bc9333 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,9 @@ Tailbone Web API - Batch Views """ -from __future__ import unicode_literals, absolute_import - import logging import warnings -import six - from cornice import Service from tailbone.api import APIMasterView @@ -70,9 +66,7 @@ class APIBatchMixin(object): """ app = self.get_rattail_app() key = self.get_batch_class().batch_key - spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key), - default=self.default_handler_spec) - return app.load_object(spec)(self.rattail_config) + return app.get_batch_handler(key, default=self.default_handler_spec) class APIBatchView(APIBatchMixin, APIMasterView): @@ -104,25 +98,25 @@ class APIBatchView(APIBatchMixin, APIMasterView): return { 'uuid': batch.uuid, - '_str': six.text_type(batch), + '_str': str(batch), 'id': batch.id, 'id_str': batch.id_str, 'description': batch.description, 'notes': batch.notes, 'params': batch.params or {}, 'rowcount': batch.rowcount, - 'created': six.text_type(created), + 'created': str(created), 'created_display': self.pretty_datetime(created), 'created_by_uuid': batch.created_by.uuid, - 'created_by_display': six.text_type(batch.created_by), + 'created_by_display': str(batch.created_by), 'complete': batch.complete, 'status_code': batch.status_code, 'status_display': batch.STATUS.get(batch.status_code, - six.text_type(batch.status_code)), - 'executed': six.text_type(executed) if executed else None, + str(batch.status_code)), + 'executed': str(executed) if executed else None, 'executed_display': self.pretty_datetime(executed) if executed else None, 'executed_by_uuid': batch.executed_by_uuid, - 'executed_by_display': six.text_type(batch.executed_by or ''), + 'executed_by_display': str(batch.executed_by or ''), 'mutable': self.batch_handler.is_mutable(batch), } @@ -273,8 +267,8 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): batch = row.batch return { 'uuid': row.uuid, - '_str': six.text_type(row), - '_parent_str': six.text_type(batch), + '_str': str(row), + '_parent_str': str(batch), '_parent_uuid': batch.uuid, 'batch_uuid': batch.uuid, 'batch_id': batch.id, @@ -285,7 +279,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): 'batch_mutable': self.batch_handler.is_mutable(batch), 'sequence': row.sequence, 'status_code': row.status_code, - 'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)), + 'status_display': row.STATUS.get(row.status_code, str(row.status_code)), } def update_object(self, row, data): @@ -320,7 +314,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): data = self.request.json_body uuid = data['batch_uuid'] - batch = self.Session.query(self.get_batch_class()).get(uuid) + batch = self.Session.get(self.get_batch_class(), uuid) if not batch: raise self.notfound() @@ -332,11 +326,14 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): log.warning("quick entry failed for '%s' batch %s: %s", self.batch_handler.batch_key, batch.id_str, entry, exc_info=True) - msg = six.text_type(error) + msg = str(error) if not msg and isinstance(error, NotImplementedError): msg = "Feature is not implemented" return {'error': msg} + if not row: + return {'error': "Could not identify product"} + self.Session.flush() result = self._get(obj=row) result['ok'] = True @@ -352,13 +349,12 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): route_prefix = cls.get_route_prefix() permission_prefix = cls.get_permission_prefix() collection_url_prefix = cls.get_collection_url_prefix() - object_url_prefix = cls.get_object_url_prefix() if cls.supports_quick_entry: # quick entry - config.add_route('{}.quick_entry'.format(route_prefix), '{}/quick-entry'.format(collection_url_prefix), - request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='quick_entry', route_name='{}.quick_entry'.format(route_prefix), - permission='{}.edit'.format(permission_prefix), - renderer='json') + quick_entry = Service(name='{}.quick_entry'.format(route_prefix), + path='{}/quick-entry'.format(collection_url_prefix)) + quick_entry.add_view('POST', 'quick_entry', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(quick_entry) diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py index 5e56fe46..22b67e54 100644 --- a/tailbone/api/batch/inventory.py +++ b/tailbone/api/batch/inventory.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,12 @@ Tailbone Web API - Inventory Batches """ -from __future__ import unicode_literals, absolute_import - import decimal -import six +import sqlalchemy as sa from rattail import pod -from rattail.db import model -from rattail.util import pretty_quantity +from rattail.db.model import InventoryBatch, InventoryBatchRow from cornice import Service @@ -41,7 +38,7 @@ from tailbone.api.batch import APIBatchView, APIBatchRowView class InventoryBatchViews(APIBatchView): - model_class = model.InventoryBatch + model_class = InventoryBatch default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' route_prefix = 'inventory' permission_prefix = 'batch.inventory' @@ -50,12 +47,12 @@ class InventoryBatchViews(APIBatchView): supports_toggle_complete = True def normalize(self, batch): - data = super(InventoryBatchViews, self).normalize(batch) + data = super().normalize(batch) data['mode'] = batch.mode data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode) if data['mode_display'] is None and batch.mode is not None: - data['mode_display'] = six.text_type(batch.mode) + data['mode_display'] = str(batch.mode) data['reason_code'] = batch.reason_code @@ -119,7 +116,7 @@ class InventoryBatchViews(APIBatchView): class InventoryBatchRowViews(APIBatchRowView): - model_class = model.InventoryBatchRow + model_class = InventoryBatchRow default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' route_prefix = 'inventory.rows' permission_prefix = 'batch.inventory' @@ -130,23 +127,24 @@ class InventoryBatchRowViews(APIBatchRowView): def normalize(self, row): batch = row.batch - data = super(InventoryBatchRowViews, self).normalize(row) + data = super().normalize(row) + app = self.get_rattail_app() data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description data['size'] = row.size data['full_description'] = row.product.full_description if row.product else row.description data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None - data['case_quantity'] = pretty_quantity(row.case_quantity or 1) + data['case_quantity'] = app.render_quantity(row.case_quantity or 1) data['cases'] = row.cases data['units'] = row.units data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' data['quantity_display'] = "{} {}".format( - pretty_quantity(row.cases or row.units), + app.render_quantity(row.cases or row.units), 'CS' if row.cases else data['unit_uom']) data['allow_cases'] = self.batch_handler.allow_cases(batch) @@ -174,7 +172,17 @@ class InventoryBatchRowViews(APIBatchRowView): data['units'] = decimal.Decimal(data['units']) # update row per usual - row = super(InventoryBatchRowViews, self).update_object(row, data) + try: + row = super().update_object(row, data) + except sa.exc.DataError as error: + # detect when user scans barcode for cases/units field + if hasattr(error, 'orig'): + orig = type(error.orig) + if hasattr(orig, '__name__'): + # nb. this particular error is from psycopg2 + if orig.__name__ == 'NumericValueOutOfRange': + return {'error': "Numeric value out of range"} + raise return row diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py index 4787aeb9..4f154b21 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Label Batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api.batch import APIBatchView, APIBatchRowView @@ -56,10 +52,10 @@ class LabelBatchRowViews(APIBatchRowView): def normalize(self, row): batch = row.batch - data = super(LabelBatchRowViews, self).normalize(row) + data = super().normalize(row) data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 9ab9617c..204be8ad 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,21 +27,24 @@ These views expose the basic CRUD interface to "ordering" batches, for the web API. """ -from __future__ import unicode_literals, absolute_import +import datetime +import logging -import six +import sqlalchemy as sa -from rattail.db import model -from rattail.util import pretty_quantity +from rattail.db.model import PurchaseBatch, PurchaseBatchRow from cornice import Service from tailbone.api.batch import APIBatchView, APIBatchRowView +log = logging.getLogger(__name__) + + class OrderingBatchViews(APIBatchView): - model_class = model.PurchaseBatch + model_class = PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'orderingbatchviews' permission_prefix = 'ordering' @@ -57,18 +60,19 @@ class OrderingBatchViews(APIBatchView): Adds a condition to the query, to ensure only purchase batches with "ordering" mode are returned. """ - query = super(OrderingBatchViews, self).base_query() + model = self.model + query = super().base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING) return query def normalize(self, batch): - data = super(OrderingBatchViews, self).normalize(batch) + data = super().normalize(batch) data['vendor_uuid'] = batch.vendor.uuid - data['vendor_display'] = six.text_type(batch.vendor) + data['vendor_display'] = str(batch.vendor) data['department_uuid'] = batch.department_uuid - data['department_display'] = six.text_type(batch.department) if batch.department else None + data['department_display'] = str(batch.department) if batch.department else None data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0) data['ship_method'] = batch.ship_method @@ -82,8 +86,10 @@ class OrderingBatchViews(APIBatchView): Sets the mode to "ordering" for the new batch. """ data = dict(data) + if not data.get('vendor_uuid'): + raise ValueError("You must specify the vendor") data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING - batch = super(OrderingBatchViews, self).create_object(data) + batch = super().create_object(data) return batch def worksheet(self): @@ -94,6 +100,8 @@ class OrderingBatchViews(APIBatchView): if batch.executed: raise self.forbidden() + app = self.get_rattail_app() + # TODO: much of the logic below was copied from the traditional master # view for ordering batches. should maybe let them share it somehow? @@ -148,7 +156,7 @@ class OrderingBatchViews(APIBatchView): product = cost.product subdept_costs.append({ 'uuid': cost.uuid, - 'upc': six.text_type(product.upc), + 'upc': str(product.upc), 'upc_pretty': product.upc.pretty() if product.upc else None, 'brand_name': product.brand.name if product.brand else None, 'description': product.description, @@ -169,8 +177,8 @@ class OrderingBatchViews(APIBatchView): # sort the (sub)department groupings sorted_departments = [] - for dept in sorted(six.itervalues(departments), key=lambda d: d['name']): - dept['subdepartments'] = sorted(six.itervalues(dept['subdepartments']), + for dept in sorted(departments.values(), key=lambda d: d['name']): + dept['subdepartments'] = sorted(dept['subdepartments'].values(), key=lambda s: s['name']) sorted_departments.append(dept) @@ -179,6 +187,18 @@ class OrderingBatchViews(APIBatchView): for i in range(6 - len(history)): history.append(None) history = list(reversed(history)) + # must convert some date objects to string, for JSON sake + for h in history: + if not h: + continue + purchase = h.get('purchase') + if purchase: + dt = purchase.get('date_ordered') + if dt and isinstance(dt, datetime.date): + purchase['date_ordered'] = app.render_date(dt) + dt = purchase.get('date_received') + if dt and isinstance(dt, datetime.date): + purchase['date_received'] = app.render_date(dt) return { 'batch': self.normalize(batch), @@ -209,7 +229,7 @@ class OrderingBatchViews(APIBatchView): class OrderingBatchRowViews(APIBatchRowView): - model_class = model.PurchaseBatchRow + model_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'ordering.rows' permission_prefix = 'ordering' @@ -219,11 +239,12 @@ class OrderingBatchRowViews(APIBatchRowView): editable = True def normalize(self, row): + data = super().normalize(row) + app = self.get_rattail_app() batch = row.batch - data = super(OrderingBatchRowViews, self).normalize(row) data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description @@ -240,15 +261,15 @@ class OrderingBatchRowViews(APIBatchRowView): data['case_quantity'] = row.case_quantity data['cases_ordered'] = row.cases_ordered data['units_ordered'] = row.units_ordered - data['cases_ordered_display'] = pretty_quantity(row.cases_ordered or 0, empty_zero=False) - data['units_ordered_display'] = pretty_quantity(row.units_ordered or 0, empty_zero=False) + data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False) + data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False) data['po_unit_cost'] = row.po_unit_cost data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None data['po_total_calculated'] = row.po_total_calculated data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None data['status_code'] = row.status_code - data['status_display'] = row.STATUS.get(row.status_code, six.text_type(row.status_code)) + data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code)) return data @@ -269,7 +290,17 @@ class OrderingBatchRowViews(APIBatchRowView): if not self.batch_handler.is_mutable(row.batch): return {'error': "Batch is not mutable"} - self.batch_handler.update_row_quantity(row, **data) + try: + self.batch_handler.update_row_quantity(row, **data) + self.Session.flush() + except Exception as error: + log.warning("update_row_quantity failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} + return row diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index c755de65..b23bff55 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,16 +24,14 @@ Tailbone Web API - Receiving Batches """ -from __future__ import unicode_literals, absolute_import - import logging -import six import humanize +import sqlalchemy as sa -from rattail.db import model -from rattail.util import pretty_quantity +from rattail.db.model import PurchaseBatch, PurchaseBatchRow +from cornice import Service from deform import widget as dfwidget from tailbone import forms @@ -46,7 +44,7 @@ log = logging.getLogger(__name__) class ReceivingBatchViews(APIBatchView): - model_class = model.PurchaseBatch + model_class = PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receivingbatchviews' permission_prefix = 'receiving' @@ -56,19 +54,21 @@ class ReceivingBatchViews(APIBatchView): supports_execute = True def base_query(self): - query = super(ReceivingBatchViews, self).base_query() + model = self.app.model + query = super().base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) return query def normalize(self, batch): - data = super(ReceivingBatchViews, self).normalize(batch) + data = super().normalize(batch) data['vendor_uuid'] = batch.vendor.uuid - data['vendor_display'] = six.text_type(batch.vendor) + data['vendor_display'] = str(batch.vendor) data['department_uuid'] = batch.department_uuid - data['department_display'] = six.text_type(batch.department) if batch.department else None + data['department_display'] = str(batch.department) if batch.department else None + data['po_number'] = batch.po_number data['po_total'] = batch.po_total data['invoice_total'] = batch.invoice_total data['invoice_total_calculated'] = batch.invoice_total_calculated @@ -79,9 +79,15 @@ class ReceivingBatchViews(APIBatchView): def create_object(self, data): data = dict(data) + + # all about receiving mode here data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING - batch = super(ReceivingBatchViews, self).create_object(data) - return batch + + # assume "receive from PO" if given a PO key + if data.get('purchase_key'): + data['workflow'] = 'from_po' + + return super().create_object(data) def auto_receive(self): """ @@ -114,8 +120,9 @@ class ReceivingBatchViews(APIBatchView): return self._get(obj=batch) def eligible_purchases(self): + model = self.app.model uuid = self.request.params.get('vendor_uuid') - vendor = self.Session.query(model.Vendor).get(uuid) if uuid else None + vendor = self.Session.get(model.Vendor, uuid) if uuid else None if not vendor: return {'error': "Vendor not found"} @@ -146,31 +153,31 @@ class ReceivingBatchViews(APIBatchView): collection_url_prefix = cls.get_collection_url_prefix() object_url_prefix = cls.get_object_url_prefix() - # auto-receive - config.add_route('{}.auto_receive'.format(route_prefix), - '{}/{{uuid}}/auto-receive'.format(object_url_prefix)) - config.add_view(cls, attr='auto_receive', - route_name='{}.auto_receive'.format(route_prefix), - permission='{}.auto_receive'.format(permission_prefix), - renderer='json') + # auto_receive + auto_receive = Service(name='{}.auto_receive'.format(route_prefix), + path='{}/{{uuid}}/auto-receive'.format(object_url_prefix)) + auto_receive.add_view('GET', 'auto_receive', klass=cls, + permission='{}.auto_receive'.format(permission_prefix)) + config.add_cornice_service(auto_receive) - # mark receiving complete - config.add_route('{}.mark_receiving_complete'.format(route_prefix), '{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix)) - config.add_view(cls, attr='mark_receiving_complete', route_name='{}.mark_receiving_complete'.format(route_prefix), - permission='{}.edit'.format(permission_prefix), - renderer='json') + # mark_receiving_complete + mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix), + path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix)) + mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_receiving_complete) # eligible purchases - config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(collection_url_prefix), - request_method='GET') - config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix), - permission='{}.create'.format(permission_prefix), - renderer='json') + eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix), + path='{}/eligible-purchases'.format(collection_url_prefix)) + eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls, + permission='{}.create'.format(permission_prefix)) + config.add_cornice_service(eligible_purchases) class ReceivingBatchRowViews(APIBatchRowView): - model_class = model.PurchaseBatchRow + model_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receiving.rows' permission_prefix = 'receiving' @@ -179,7 +186,8 @@ class ReceivingBatchRowViews(APIBatchRowView): supports_quick_entry = True def make_filter_spec(self): - filters = super(ReceivingBatchRowViews, self).make_filter_spec() + model = self.app.model + filters = super().make_filter_spec() if filters: # must translate certain convenience filters @@ -275,21 +283,30 @@ class ReceivingBatchRowViews(APIBatchRowView): ]}, ]) + # is_missing + elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True: + filters.extend([ + {'or': [ + {'field': 'cases_missing', 'op': '!=', 'value': 0}, + {'field': 'units_missing', 'op': '!=', 'value': 0}, + ]}, + ]) + else: # just some filter, use as-is filters.append(filtr) return filters def normalize(self, row): - data = super(ReceivingBatchRowViews, self).normalize(row) + data = super().normalize(row) + model = self.app.model batch = row.batch - app = self.get_rattail_app() - prodder = app.get_products_handler() + prodder = self.app.get_products_handler() data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description @@ -321,6 +338,9 @@ class ReceivingBatchRowViews(APIBatchRowView): data['cases_expired'] = row.cases_expired data['units_expired'] = row.units_expired + data['cases_missing'] = row.cases_missing + data['units_missing'] = row.units_missing + cases, units = self.batch_handler.get_unconfirmed_counts(row) data['cases_unconfirmed'] = cases data['units_unconfirmed'] = units @@ -328,6 +348,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['po_unit_cost'] = row.po_unit_cost data['po_total'] = row.po_total + data['invoice_number'] = row.invoice_number data['invoice_unit_cost'] = row.invoice_unit_cost data['invoice_total'] = row.invoice_total data['invoice_total_calculated'] = row.invoice_total_calculated @@ -356,7 +377,7 @@ class ReceivingBatchRowViews(APIBatchRowView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = pretty_quantity(remainder) + remainder = self.app.render_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive Remainder ({} {})".format( remainder, data['unit_uom']) @@ -367,7 +388,7 @@ class ReceivingBatchRowViews(APIBatchRowView): else: # nothing yet accounted for, button should receive "all" if not remainder: log.warning("quick receive remainder is empty for row %s", row.uuid) - remainder = pretty_quantity(remainder) + remainder = self.app.render_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive ALL ({} {})".format( remainder, data['unit_uom']) @@ -395,7 +416,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['received_alert'] = None if self.batch_handler.get_units_confirmed(row): msg = "You have already received some of this product; last update was {}.".format( - humanize.naturaltime(app.make_utc() - row.modified)) + humanize.naturaltime(self.app.make_utc() - row.modified)) data['received_alert'] = msg return data @@ -404,27 +425,37 @@ class ReceivingBatchRowViews(APIBatchRowView): """ View which handles "receiving" against a particular batch row. """ + model = self.app.model + # first do basic input validation schema = ReceiveRow().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) # TODO: this seems hacky, but avoids "complex" date value parsing form.set_widget('expiration_date', dfwidget.TextInputWidget()) - if not form.validate(newstyle=True): - log.debug("form did not validate: %s", - form.make_deform_form().error) + if not form.validate(): + log.warning("form did not validate: %s", + form.make_deform_form().error) return {'error': "Form did not validate"} # fetch / validate row object - row = self.Session.query(model.PurchaseBatchRow).get(form.validated['row']) + row = self.Session.get(model.PurchaseBatchRow, form.validated['row']) if row is not self.get_object(): return {'error': "Specified row does not match the route!"} # handler takes care of the row receiving logic for us kwargs = dict(form.validated) del kwargs['row'] - self.batch_handler.receive_row(row, **kwargs) + try: + self.batch_handler.receive_row(row, **kwargs) + self.Session.flush() + except Exception as error: + log.warning("receive() failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} - self.Session.flush() return self._get(obj=row) @classmethod @@ -440,11 +471,11 @@ class ReceivingBatchRowViews(APIBatchRowView): object_url_prefix = cls.get_object_url_prefix() # receive (row) - config.add_route('{}.receive'.format(route_prefix), '{}/{{uuid}}/receive'.format(object_url_prefix), - request_method=('OPTIONS', 'POST')) - config.add_view(cls, attr='receive', route_name='{}.receive'.format(route_prefix), - permission='{}.edit_row'.format(permission_prefix), - renderer='json') + receive = Service(name='{}.receive'.format(route_prefix), + path='{}/{{uuid}}/receive'.format(object_url_prefix)) + receive.add_view('POST', 'receive', klass=cls, + permission='{}.edit_row'.format(permission_prefix)) + config.add_cornice_service(receive) def defaults(config, **kwargs): diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 3e96609a..6cacfb06 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,16 +24,14 @@ Tailbone Web API - "Common" Views """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict -import rattail -from rattail.db import model -from rattail.mail import send_email -from rattail.util import OrderedDict +from rattail.util import get_pkg_version from cornice import Service +from cornice.service import get_services +from cornice_swagger import CorniceSwagger -import tailbone from tailbone import forms from tailbone.forms.common import Feedback from tailbone.api import APIView, api @@ -65,11 +63,12 @@ class CommonView(APIView): } def get_project_title(self): - return self.rattail_config.app_title(default="Tailbone") + app = self.get_rattail_app() + return app.get_title() def get_project_version(self): - import tailbone - return tailbone.__version__ + app = self.get_rattail_app() + return app.get_version() def get_packages(self): """ @@ -77,8 +76,8 @@ class CommonView(APIView): 'about' page. """ return OrderedDict([ - ('rattail', rattail.__version__), - ('Tailbone', tailbone.__version__), + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), ]) @api @@ -86,18 +85,20 @@ class CommonView(APIView): """ View to handle user feedback form submits. """ + app = self.get_rattail_app() + model = self.model # TODO: this logic was copied from tailbone.views.common and is largely # identical; perhaps should merge somehow? schema = Feedback().bind(session=Session()) form = forms.Form(schema=schema, request=self.request) - if form.validate(newstyle=True): + if form.validate(): data = dict(form.validated) # figure out who the sending user is, if any if self.request.user: data['user'] = self.request.user elif data['user']: - data['user'] = Session.query(model.User).get(data['user']) + data['user'] = Session.get(model.User, data['user']) # TODO: should provide URL to view user if data['user']: @@ -105,17 +106,27 @@ class CommonView(APIView): data['client_ip'] = self.request.client_addr email_key = data['email_key'] or self.feedback_email_key - send_email(self.rattail_config, email_key, data=data) + app.send_email(email_key, data=data) return {'ok': True} return {'error': "Form did not validate!"} + def swagger(self): + doc = CorniceSwagger(get_services()) + app = self.get_rattail_app() + spec = doc.generate(f"{app.get_node_title()} API docs", + app.get_version(), + base_path='/api') # TODO + return spec + @classmethod def defaults(cls, config): cls._common_defaults(config) @classmethod def _common_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + app = rattail_config.get_app() # about about = Service(name='about', path='/about') @@ -128,6 +139,14 @@ class CommonView(APIView): permission='common.feedback') config.add_cornice_service(feedback) + # swagger + swagger = Service(name='swagger', + path='/swagger.json', + description=f"OpenAPI documentation for {app.get_title()}") + swagger.add_view('GET', 'swagger', klass=cls, + permission='common.api_swagger') + config.add_cornice_service(swagger) + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/api/core.py b/tailbone/api/core.py index 613b1566..0d8eec32 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - Core Views """ -from __future__ import unicode_literals, absolute_import - from tailbone.views import View @@ -101,20 +99,20 @@ class APIView(View): return info """ app = self.get_rattail_app() - auth_handler = app.get_auth_handler() + auth = app.get_auth_handler() # basic / default info - is_admin = user.is_admin() - employee = user.employee + is_admin = auth.user_is_admin(user) + employee = app.get_employee(user) info = { 'uuid': user.uuid, 'username': user.username, 'display_name': user.display_name, - 'short_name': user.get_short_name(), + 'short_name': auth.get_short_display_name(user), 'is_admin': is_admin, 'is_root': is_admin and self.request.session.get('is_root', False), 'employee_uuid': employee.uuid if employee else None, - 'email_address': auth_handler.get_email_address(user), + 'email_address': app.get_contact_email_address(user), } # maybe get/use "extra" info diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index e9953572..85d28c24 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Customer Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -46,7 +42,7 @@ class CustomerView(APIMasterView): def normalize(self, customer): return { 'uuid': customer.uuid, - '_str': six.text_type(customer), + '_str': str(customer), 'id': customer.id, 'number': customer.number, 'name': customer.name, diff --git a/tailbone/api/essentials.py b/tailbone/api/essentials.py new file mode 100644 index 00000000..7b151578 --- /dev/null +++ b/tailbone/api/essentials.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Essential views for convenient includes +""" + + +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + config.include(mod('tailbone.api.auth')) + config.include(mod('tailbone.api.common')) + + +def includeme(config): + defaults(config) diff --git a/tailbone/scaffolds.py b/tailbone/api/labels.py similarity index 57% rename from tailbone/scaffolds.py rename to tailbone/api/labels.py index 10bf9640..8bc11f8f 100644 --- a/tailbone/scaffolds.py +++ b/tailbone/api/labels.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -21,25 +21,31 @@ # ################################################################################ """ -Pyramid scaffold templates +Tailbone Web API - Label Views """ from __future__ import unicode_literals, absolute_import -from rattail.files import resource_path -from rattail.util import prettify +from rattail.db.model import LabelProfile -from pyramid.scaffolds import PyramidTemplate +from tailbone.api import APIMasterView -class RattailTemplate(PyramidTemplate): - _template_dir = resource_path('rattail:data/project') - summary = "Starter project based on Rattail / Tailbone" +class LabelProfileView(APIMasterView): + """ + API views for Label Profile data + """ + model_class = LabelProfile + collection_url_prefix = '/label-profiles' + object_url_prefix = '/label-profile' - def pre(self, command, output_dir, vars): - """ - Adds some more variables to the template context. - """ - vars['project_title'] = prettify(vars['project']) - vars['package_title'] = vars['package'].capitalize() - return super(RattailTemplate, self).pre(command, output_dir, vars) + +def defaults(config, **kwargs): + base = globals() + + LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView']) + LabelProfileView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 97426214..551d6428 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,26 +24,15 @@ Tailbone Web API - Master View """ -from __future__ import unicode_literals, absolute_import - import json -import six - -from rattail.config import parse_bool from rattail.db.util import get_fieldnames from cornice import resource, Service -from tailbone.api import APIView, api +from tailbone.api import APIView from tailbone.db import Session - - -class SortColumn(object): - - def __init__(self, field_name, model_name=None): - self.field_name = field_name - self.model_name = model_name +from tailbone.util import SortColumn class APIMasterView(APIView): @@ -195,7 +184,7 @@ class APIMasterView(APIView): if sortcol: spec = { 'field': sortcol.field_name, - 'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc', + 'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc', } if sortcol.model_name: spec['model'] = sortcol.model_name @@ -279,7 +268,7 @@ class APIMasterView(APIView): return self._fieldnames def normalize(self, obj): - data = {'_str': six.text_type(obj)} + data = {'_str': str(obj)} for field in self.get_fieldnames(): data[field] = getattr(obj, field) @@ -287,7 +276,7 @@ class APIMasterView(APIView): return data def _collection_get(self): - from sqlalchemy_filters import apply_filters, apply_sort, apply_pagination + from sa_filters import apply_filters, apply_sort, apply_pagination query = self.base_query() context = {} @@ -343,7 +332,7 @@ class APIMasterView(APIView): if not uuid: uuid = self.request.matchdict['uuid'] - obj = self.Session.query(self.get_model_class()).get(uuid) + obj = self.Session.get(self.get_model_class(), uuid) if obj: return obj @@ -365,9 +354,13 @@ class APIMasterView(APIView): data = self.request.json_body # add instance to session, and return data for it - obj = self.create_object(data) - self.Session.flush() - return self._get(obj) + try: + obj = self.create_object(data) + except Exception as error: + return self.json_response({'error': str(error)}) + else: + self.Session.flush() + return self._get(obj) def create_object(self, data): """ @@ -394,7 +387,7 @@ class APIMasterView(APIView): """ if not uuid: uuid = self.request.matchdict['uuid'] - obj = self.Session.query(self.get_model_class()).get(uuid) + obj = self.Session.get(self.get_model_class(), uuid) if not obj: raise self.notfound() @@ -479,13 +472,16 @@ class APIMasterView(APIView): """ obj = self.get_object() - filename = self.request.GET.get('filename', None) - if not filename: - raise self.notfound() - path = self.download_path(obj, filename) + # TODO: is this really needed? + # filename = self.request.GET.get('filename', None) + # if filename: + # path = self.download_path(obj, filename) + # return self.file_response(path, attachment=False) - response = self.file_response(path, attachment=False) - return response + return self.rawbytes_response(obj) + + def rawbytes_response(self, obj): + raise NotImplementedError ############################## # autocomplete diff --git a/tailbone/api/people.py b/tailbone/api/people.py index 7e06e969..f7c08dfa 100644 --- a/tailbone/api/people.py +++ b/tailbone/api/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Person Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -45,7 +41,7 @@ class PersonView(APIMasterView): def normalize(self, person): return { 'uuid': person.uuid, - '_str': six.text_type(person), + '_str': str(person), 'first_name': person.first_name, 'last_name': person.last_name, 'display_name': person.display_name, diff --git a/tailbone/api/products.py b/tailbone/api/products.py index 48a6e4aa..3f29ff54 100644 --- a/tailbone/api/products.py +++ b/tailbone/api/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,17 +24,21 @@ Tailbone Web API - Product Views """ -from __future__ import unicode_literals, absolute_import +import logging -import six import sqlalchemy as sa from sqlalchemy import orm +from cornice import Service + from rattail.db import model from tailbone.api import APIMasterView +log = logging.getLogger(__name__) + + class ProductView(APIMasterView): """ API views for Product data @@ -44,20 +48,49 @@ class ProductView(APIMasterView): object_url_prefix = '/product' supports_autocomplete = True + def __init__(self, request, context=None): + super(ProductView, self).__init__(request, context=context) + app = self.get_rattail_app() + self.products_handler = app.get_products_handler() + def normalize(self, product): + + # get what we can from handler + data = self.products_handler.normalize_product(product, fields=[ + 'brand_name', + 'full_description', + 'department_name', + 'unit_price_display', + 'sale_price', + 'sale_price_display', + 'sale_ends', + 'sale_ends_display', + 'tpr_price', + 'tpr_price_display', + 'tpr_ends', + 'tpr_ends_display', + 'current_price', + 'current_price_display', + 'current_ends', + 'current_ends_display', + 'vendor_name', + 'costs', + 'image_url', + ]) + + # but must supplement cost = product.cost - return { - 'uuid': product.uuid, - '_str': six.text_type(product), - 'upc': six.text_type(product.upc), + data.update({ + 'upc': str(product.upc), 'scancode': product.scancode, 'item_id': product.item_id, 'item_type': product.item_type, - 'description': product.description, 'status_code': product.status_code, 'default_unit_cost': cost.unit_cost if cost else None, 'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None, - } + }) + + return data def make_autocomplete_query(self, term): query = self.Session.query(model.Product)\ @@ -77,6 +110,104 @@ class ProductView(APIMasterView): def autocomplete_display(self, product): return product.full_description + def quick_lookup(self): + """ + View for handling "quick lookup" user input, for index page. + """ + data = self.request.GET + entry = data['entry'] + + product = self.products_handler.locate_product_for_entry(self.Session(), + entry) + if not product: + return {'error': "Product not found"} + + return {'ok': True, + 'product': self.normalize(product)} + + def label_profiles(self): + """ + Returns the set of label profiles available for use with + printing label for product. + """ + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + + profiles = [] + for profile in label_handler.get_label_profiles(self.Session()): + profiles.append({ + 'uuid': profile.uuid, + 'description': profile.description, + }) + + return {'label_profiles': profiles} + + def print_labels(self): + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + data = self.request.json_body + + uuid = data.get('label_profile_uuid') + profile = self.Session.get(model.LabelProfile, uuid) if uuid else None + if not profile: + return {'error': "Label profile not found"} + + uuid = data.get('product_uuid') + product = self.Session.get(model.Product, uuid) if uuid else None + if not product: + return {'error': "Product not found"} + + try: + quantity = int(data.get('quantity')) + except: + return {'error': "Quantity must be integer"} + + printer = label_handler.get_printer(profile) + if not printer: + return {'error': "Couldn't get printer from label profile"} + + try: + printer.print_labels([({'product': product}, quantity)]) + except Exception as error: + log.warning("error occurred while printing labels", exc_info=True) + return {'error': str(error)} + + return {'ok': True} + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._product_defaults(config) + + @classmethod + def _product_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + + # quick lookup + quick_lookup = Service(name='{}.quick_lookup'.format(route_prefix), + path='{}/quick-lookup'.format(collection_url_prefix)) + quick_lookup.add_view('GET', 'quick_lookup', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(quick_lookup) + + # label profiles + label_profiles = Service(name=f'{route_prefix}.label_profiles', + path=f'{collection_url_prefix}/label-profiles') + label_profiles.add_view('GET', 'label_profiles', klass=cls, + permission=f'{permission_prefix}.print_labels') + config.add_cornice_service(label_profiles) + + # print labels + print_labels = Service(name='{}.print_labels'.format(route_prefix), + path='{}/print-labels'.format(collection_url_prefix)) + print_labels.add_view('POST', 'print_labels', klass=cls, + permission='{}.print_labels'.format(permission_prefix)) + config.add_cornice_service(print_labels) + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 6ce5f778..467c8a0d 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Upgrade Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -53,7 +49,7 @@ class UpgradeView(APIMasterView): data['status_code'] = None else: data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code, - six.text_type(upgrade.status_code)) + str(upgrade.status_code)) return data diff --git a/tailbone/api/users.py b/tailbone/api/users.py index 2b6476a2..a6bcad57 100644 --- a/tailbone/api/users.py +++ b/tailbone/api/users.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - User Views """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model from tailbone.api import APIMasterView @@ -57,6 +55,10 @@ class UserView(APIMasterView): query = query.outerjoin(model.Person) return query + def update_object(self, user, data): + # TODO: should ensure prevent_password_change is respected + return super(UserView, self).update_object(user, data) + def defaults(config, **kwargs): base = globals() diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py index 7fa61590..64311b1b 100644 --- a/tailbone/api/vendors.py +++ b/tailbone/api/vendors.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -44,7 +40,7 @@ class VendorView(APIMasterView): def normalize(self, vendor): return { 'uuid': vendor.uuid, - '_str': six.text_type(vendor), + '_str': str(vendor), 'id': vendor.id, 'name': vendor.name, } diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py index eabe4cdb..19def6c4 100644 --- a/tailbone/api/workorders.py +++ b/tailbone/api/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Tailbone Web API - Work Order Views """ -from __future__ import unicode_literals, absolute_import - import datetime -import six - from rattail.db.model import WorkOrder from cornice import Service @@ -44,19 +40,19 @@ class WorkOrderView(APIMasterView): object_url_prefix = '/workorder' def __init__(self, *args, **kwargs): - super(WorkOrderView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def normalize(self, workorder): - data = super(WorkOrderView, self).normalize(workorder) + data = super().normalize(workorder) data.update({ 'customer_name': workorder.customer.name, 'status_label': self.enum.WORKORDER_STATUS[workorder.status_code], - 'date_submitted': six.text_type(workorder.date_submitted or ''), - 'date_received': six.text_type(workorder.date_received or ''), - 'date_released': six.text_type(workorder.date_released or ''), - 'date_delivered': six.text_type(workorder.date_delivered or ''), + 'date_submitted': str(workorder.date_submitted or ''), + 'date_received': str(workorder.date_received or ''), + 'date_released': str(workorder.date_released or ''), + 'date_delivered': str(workorder.date_delivered or ''), }) return data @@ -87,7 +83,7 @@ class WorkOrderView(APIMasterView): if 'status_code' in data: data['status_code'] = int(data['status_code']) - return super(WorkOrderView, self).update_object(workorder, data) + return super().update_object(workorder, data) def status_codes(self): """ diff --git a/tailbone/app.py b/tailbone/app.py index d7155829..d2d0c5ef 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,25 +24,20 @@ Application Entry Point """ -from __future__ import unicode_literals, absolute_import - import os -import warnings -import six -import sqlalchemy as sa from sqlalchemy.orm import sessionmaker, scoped_session -from rattail.config import make_config, parse_list +from wuttjamaican.util import parse_list + +from rattail.config import make_config from rattail.exceptions import ConfigurationError -from rattail.db.types import GPCType from pyramid.config import Configurator -from pyramid.authentication import SessionAuthenticationPolicy from zope.sqlalchemy import register import tailbone.db -from tailbone.auth import TailboneAuthorizationPolicy +from tailbone.auth import TailboneSecurityPolicy from tailbone.config import csrf_token_name, csrf_header_name from tailbone.util import get_effective_theme, get_theme_template_path from tailbone.providers import get_all_providers @@ -63,16 +58,33 @@ def make_rattail_config(settings): "to the path of your config file. Lame, but necessary.") rattail_config = make_config(path) settings['rattail_config'] = rattail_config - rattail_config.configure_logging() + + # nb. this is for compaibility with wuttaweb + settings['wutta_config'] = rattail_config + + # must import all sqlalchemy models before things get rolling, + # otherwise can have errors about continuum TransactionMeta class + # not yet mapped, when relevant pages are first requested... + # cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models + # hat tip to https://stackoverflow.com/a/59241485 + if getattr(rattail_config, 'tempmon_engine', None): + from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession + tempmon_session = TempmonSession() + tempmon_session.query(tempmon_model.Appliance).first() + tempmon_session.close() # configure database sessions - if hasattr(rattail_config, 'rattail_engine'): - tailbone.db.Session.configure(bind=rattail_config.rattail_engine) + if hasattr(rattail_config, 'appdb_engine'): + tailbone.db.Session.configure(bind=rattail_config.appdb_engine) if hasattr(rattail_config, 'trainwreck_engine'): tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) if hasattr(rattail_config, 'tempmon_engine'): tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine) + # maybe set "future" behavior for SQLAlchemy + if rattail_config.getbool('rattail.db', 'sqlalchemy_future_mode', usedb=False): + tailbone.db.Session.configure(future=True) + # create session wrappers for each "extra" Trainwreck engine for key, engine in rattail_config.trainwreck_engines.items(): if key != 'default': @@ -123,18 +135,21 @@ def make_pyramid_config(settings, configure_csrf=True): config.set_root_factory(Root) else: + # declare this web app of the "classic" variety + settings.setdefault('tailbone.classic', 'true') + # we want the new themes feature! establish_theme(settings) + settings.setdefault('fanstatic.versioning', 'true') settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') config = Configurator(settings=settings, root_factory=Root) - # add rattail config directly to registry + # add rattail config directly to registry, for access throughout the app config.registry['rattail_config'] = rattail_config # configure user authorization / authentication - config.set_authorization_policy(TailboneAuthorizationPolicy()) - config.set_authentication_policy(SessionAuthenticationPolicy()) + config.set_security_policy(TailboneSecurityPolicy()) # maybe require CSRF token protection if configure_csrf: @@ -145,9 +160,20 @@ def make_pyramid_config(settings, configure_csrf=True): # Bring in some Pyramid goodies. config.include('tailbone.beaker') config.include('pyramid_deform') + config.include('pyramid_fanstatic') config.include('pyramid_mako') config.include('pyramid_tm') + # TODO: this may be a good idea some day, if wanting to leverage + # deform resources for component JS? cf. also base.mako template + # # override default script mapping for deform + # from deform import Field + # from deform.widget import ResourceRegistry, default_resources + # registry = ResourceRegistry(use_defaults=False) + # for key in default_resources: + # registry.set_js_resources(key, None, {'js': []}) + # Field.set_default_resource_registry(registry) + # bring in the pyramid_retry logic, if available # TODO: pretty soon we can require this package, hopefully.. try: @@ -159,7 +185,7 @@ def make_pyramid_config(settings, configure_csrf=True): # fetch all tailbone providers providers = get_all_providers(rattail_config) - for provider in six.itervalues(providers): + for provider in providers.values(): # configure DB sessions associated with transaction manager provider.configure_db_sessions(rattail_config, config) @@ -170,13 +196,22 @@ def make_pyramid_config(settings, configure_csrf=True): for spec in includes: config.include(spec) - # Add some permissions magic. - config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + # add some permissions magic + config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + # TODO: deprecate / remove these + config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') # and some similar magic for certain master views config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page') + config.add_directive('add_tailbone_model_view', 'tailbone.app.add_model_view') + config.add_directive('add_tailbone_view_supplement', 'tailbone.app.add_view_supplement') config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket') @@ -191,7 +226,7 @@ def add_websocket(config, name, view, attr=None): rattail_config = config.registry.settings['rattail_config'] rattail_app = rattail_config.get_app() - if isinstance(view, six.string_types): + if isinstance(view, str): view_callable = rattail_app.load_object(view) else: view_callable = view @@ -239,6 +274,36 @@ def add_config_page(config, route_name, label, permission): config.action(None, action) +def add_model_view(config, model_name, label, route_prefix, permission_prefix): + """ + Register a model view for the app. + """ + def action(): + all_views = config.get_settings().get('tailbone_model_views', {}) + + model_views = all_views.setdefault(model_name, []) + model_views.append({ + 'label': label, + 'route_prefix': route_prefix, + 'permission_prefix': permission_prefix, + }) + + config.add_settings({'tailbone_model_views': all_views}) + + config.action(None, action) + + +def add_view_supplement(config, route_prefix, cls): + """ + Register a master view supplement for the app. + """ + def action(): + supplements = config.get_settings().get('tailbone_view_supplements', {}) + supplements.setdefault(route_prefix, []).append(cls) + config.add_settings({'tailbone_view_supplements': supplements}) + config.action(None, action) + + def establish_theme(settings): rattail_config = settings['rattail_config'] @@ -246,7 +311,7 @@ def establish_theme(settings): settings['tailbone.theme'] = theme directories = settings['mako.directories'] - if isinstance(directories, six.string_types): + if isinstance(directories, str): directories = parse_list(directories) path = get_theme_template_path(rattail_config) @@ -267,7 +332,8 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - settings.setdefault('mako.directories', ['tailbone:templates']) + settings.setdefault('mako.directories', ['tailbone:templates', + 'wuttaweb:templates']) rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) pyramid_config.include('tailbone') diff --git a/tailbone/asgi.py b/tailbone/asgi.py index f2146577..1afbe12a 100644 --- a/tailbone/asgi.py +++ b/tailbone/asgi.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,14 +24,10 @@ ASGI App Utilities """ -from __future__ import unicode_literals, absolute_import - import os +import configparser import logging -import six -from six.moves import configparser - from rattail.util import load_object from asgiref.wsgi import WsgiToAsgi @@ -49,6 +45,12 @@ class TailboneWsgiToAsgi(WsgiToAsgi): protocol = scope['type'] path = scope['path'] + # strip off the root path, if non-empty. needed for serving + # under /poser or anything other than true site root + root_path = scope['root_path'] + if root_path and path.startswith(root_path): + path = path[len(root_path):] + if protocol == 'websocket': websockets = self.wsgi_application.registry.get( 'tailbone_websockets', {}) @@ -85,7 +87,7 @@ def make_asgi_app(main_app=None): # parse the settings needed for pyramid app settings = dict(parser.items('app:main')) - if isinstance(main_app, six.string_types): + if isinstance(main_app, str): make_wsgi_app = load_object(main_app) elif callable(main_app): make_wsgi_app = main_app diff --git a/tailbone/auth.py b/tailbone/auth.py index 88fbab0b..95bf90ba 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,32 +24,31 @@ Authentication & Authorization """ -from __future__ import unicode_literals, absolute_import - import logging +import re -from rattail import enum -from rattail.util import prettify, NOTSET +from wuttjamaican.util import UNSPECIFIED -from zope.interface import implementer -from pyramid.interfaces import IAuthorizationPolicy -from pyramid.security import remember, forget, Everyone, Authenticated +from pyramid.security import remember, forget +from wuttaweb.auth import WuttaSecurityPolicy from tailbone.db import Session log = logging.getLogger(__name__) -def login_user(request, user, timeout=NOTSET): +def login_user(request, user, timeout=UNSPECIFIED): """ Perform the steps necessary to login the given user. Note that this returns a ``headers`` dict which you should pass to the redirect. """ - user.record_event(enum.USER_EVENT_LOGIN) + config = request.rattail_config + app = config.get_app() + user.record_event(app.enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) - if timeout is NOTSET: - timeout = session_timeout_for_user(user) + if timeout is UNSPECIFIED: + timeout = session_timeout_for_user(config, user) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) set_session_timeout(request, timeout) return headers @@ -60,24 +59,28 @@ def logout_user(request): Perform the logout action for the given request. Note that this returns a ``headers`` dict which you should pass to the redirect. """ + app = request.rattail_config.get_app() user = request.user if user: - user.record_event(enum.USER_EVENT_LOGOUT) + user.record_event(app.enum.USER_EVENT_LOGOUT) request.session.delete() request.session.invalidate() headers = forget(request) return headers -def session_timeout_for_user(user): +def session_timeout_for_user(config, user): """ Returns the "max" session timeout for the user, according to roles """ - from rattail.db.auth import authenticated_role + app = config.get_app() + auth = app.get_auth_handler() - roles = user.roles + [authenticated_role(Session())] + authenticated = auth.get_role_authenticated(Session()) + roles = user.roles + [authenticated] timeouts = [role.session_timeout for role in roles if role.session_timeout is not None] + if timeouts and 0 not in timeouts: return max(timeouts) @@ -89,58 +92,42 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None -@implementer(IAuthorizationPolicy) -class TailboneAuthorizationPolicy(object): +class TailboneSecurityPolicy(WuttaSecurityPolicy): - def permits(self, context, principals, permission): - config = context.request.rattail_config - model = config.get_model() + def __init__(self, db_session=None, api_mode=False, **kwargs): + kwargs['db_session'] = db_session or Session() + super().__init__(**kwargs) + self.api_mode = api_mode + + def load_identity(self, request): + config = request.registry.settings.get('rattail_config') app = config.get_app() - auth = app.get_auth_handler() + user = None - for userid in principals: - if userid not in (Everyone, Authenticated): - if context.request.user and context.request.user.uuid == userid: - return context.request.has_perm(permission) - else: - # this is pretty rare, but can happen in dev after - # re-creating the database, which means new user uuids. - # TODO: the odds of this query returning a user in that - # case, are probably nil, and we should just skip this bit? - user = Session.query(model.User).get(userid) - if user: - if auth.has_permission(Session(), user, permission): - return True - if Everyone in principals: - return auth.has_permission(Session(), None, permission) - return False + if self.api_mode: - def principals_allowed_by_permission(self, context, permission): - raise NotImplementedError + # determine/load user from header token if present + credentials = request.headers.get('Authorization') + if credentials: + match = re.match(r'^Bearer (\S+)$', credentials) + if match: + token = match.group(1) + auth = app.get_auth_handler() + user = auth.authenticate_user_token(self.db_session, token) + if not user: -def add_permission_group(config, key, label=None, overwrite=True): - """ - Add a permission group to the app configuration. - """ - def action(): - perms = config.get_settings().get('tailbone_permissions', {}) - if key not in perms or overwrite: - group = perms.setdefault(key, {'key': key}) - group['label'] = label or prettify(key) - config.add_settings({'tailbone_permissions': perms}) - config.action(None, action) + # fetch user uuid from current session + uuid = self.session_helper.authenticated_userid(request) + if not uuid: + return + # fetch user object from db + model = app.model + user = self.db_session.get(model.User, uuid) + if not user: + return -def add_permission(config, groupkey, key, label=None): - """ - Add a permission to the app configuration. - """ - def action(): - perms = config.get_settings().get('tailbone_permissions', {}) - group = perms.setdefault(groupkey, {'key': groupkey}) - group.setdefault('label', prettify(groupkey)) - perm = group.setdefault('perms', {}).setdefault(key, {'key': key}) - perm['label'] = label or prettify(key) - config.add_settings({'tailbone_permissions': perms}) - config.action(None, action) + # this user is responsible for data changes in current request + self.db_session.set_continuum_user(user) + return user diff --git a/tailbone/beaker.py b/tailbone/beaker.py index b5d592f1..25a450df 100644 --- a/tailbone/beaker.py +++ b/tailbone/beaker.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,11 +27,11 @@ Note that most of the code for this module was copied from the beaker and pyramid_beaker projects. """ -from __future__ import unicode_literals, absolute_import - import time from pkg_resources import parse_version +from rattail.util import get_pkg_version + import beaker from beaker.session import Session from beaker.util import coerce_session_params @@ -49,7 +49,7 @@ class TailboneSession(Session): "Loads the data from this session from persistent storage" # are we using older version of beaker? - old_beaker = parse_version(beaker.__version__) < parse_version('1.12') + old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12') self.namespace = self.namespace_class(self.id, data_dir=self.data_dir, 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 . +# +################################################################################ +""" +Cleanup logic +""" + +from __future__ import unicode_literals, absolute_import + +import os +import logging +import time + +from rattail.cleanup import Cleaner + + +log = logging.getLogger(__name__) + + +class BeakerCleaner(Cleaner): + """ + Cleanup logic for old Beaker session files. + """ + + def get_session_dir(self): + session_dir = self.config.get('rattail.cleanup', 'beaker.session_dir') + if session_dir and os.path.isdir(session_dir): + return session_dir + + session_dir = os.path.join(self.config.appdir(), 'sessions') + if os.path.isdir(session_dir): + return session_dir + + def cleanup(self, session, dry_run=False, progress=None, **kwargs): + session_dir = self.get_session_dir() + if not session_dir: + return + + data_dir = os.path.join(session_dir, 'data') + lock_dir = os.path.join(session_dir, 'lock') + + # looking for files older than X days + days = self.config.getint('rattail.cleanup', + 'beaker.session_cutoff_days', + default=30) + cutoff = time.time() - 3600 * 24 * days + + for topdir in (data_dir, lock_dir): + if not os.path.isdir(topdir): + continue + + for dirpath, dirnames, filenames in os.walk(topdir): + for fname in filenames: + path = os.path.join(dirpath, fname) + ts = os.path.getmtime(path) + if ts <= cutoff: + if dry_run: + log.debug("would delete file: %s", path) + else: + os.remove(path) + log.debug("deleted file: %s", path) diff --git a/tailbone/config.py b/tailbone/config.py index 4c393b49..8392ba0a 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,15 +24,16 @@ Rattail config extension for Tailbone """ -from __future__ import unicode_literals, absolute_import +import warnings + +from wuttjamaican.conf import WuttaConfigExtension -from rattail.config import ConfigExtension as BaseExtension from rattail.db.config import configure_session from tailbone.db import Session -class ConfigExtension(BaseExtension): +class ConfigExtension(WuttaConfigExtension): """ Rattail config extension for Tailbone. Does the following: @@ -49,9 +50,12 @@ class ConfigExtension(BaseExtension): configure_session(config, Session) # provide default theme selection - config.setdefault('tailbone', 'themes', 'default, falafel') + config.setdefault('tailbone', 'themes.keys', 'default, butterball') config.setdefault('tailbone', 'themes.expose_picker', 'true') + # override oruga detection + config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga') + def csrf_token_name(config): return config.get('tailbone', 'csrf_token_name', default='_csrf') diff --git a/tailbone/db.py b/tailbone/db.py index ae919e49..8b37f399 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2021 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -21,16 +21,13 @@ # ################################################################################ """ -Database Stuff +Database sessions etc. """ -from __future__ import unicode_literals, absolute_import - import sqlalchemy as sa from zope.sqlalchemy import datamanager import sqlalchemy_continuum as continuum from sqlalchemy.orm import sessionmaker, scoped_session -from pkg_resources import get_distribution, parse_version from rattail.db import SessionBase from rattail.db.continuum import versioning_manager @@ -45,23 +42,28 @@ TrainwreckSession = scoped_session(sessionmaker()) # empty dict for now, this must populated on app startup (if needed) ExtraTrainwreckSessions = {} -# some of the logic below may need to vary somewhat, based on which version of -# zope.sqlalchemy we have installed -zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version -zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version) - class TailboneSessionDataManager(datamanager.SessionDataManager): - """Integrate a top level sqlalchemy session transaction into a zope transaction + """ + Integrate a top level sqlalchemy session transaction into a zope + transaction One phase variant. .. note:: - This class appears to be necessary in order for the Continuum - integration to work alongside the Zope transaction integration. + + This class appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It subclasses + ``zope.sqlalchemy.datamanager.SessionDataManager`` but injects + some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and + is sort of monkey-patched into the mix. """ def tpc_vote(self, trans): + """ """ # for a one phase data manager commit last in tpc_vote if self.tx is not None: # there may have been no work to do @@ -73,82 +75,117 @@ class TailboneSessionDataManager(datamanager.SessionDataManager): self._finish('committed') -def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False): - """Join a session to a transaction using the appropriate datamanager. +def join_transaction( + session, + initial_state=datamanager.STATUS_ACTIVE, + transaction_manager=datamanager.zope_transaction.manager, + keep_session=False, +): + """ + Join a session to a transaction using the appropriate datamanager. - It is safe to call this multiple times, if the session is already joined - then it just returns. + It is safe to call this multiple times, if the session is already + joined then it just returns. - `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY + `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or + STATUS_READONLY - If using the default initial status of STATUS_ACTIVE, you must ensure that - mark_changed(session) is called when data is written to the database. + If using the default initial status of STATUS_ACTIVE, you must + ensure that mark_changed(session) is called when data is written + to the database. - The ZopeTransactionExtesion SessionExtension can be used to ensure that this is - called automatically after session write operations. + The ZopeTransactionExtesion SessionExtension can be used to ensure + that this is called automatically after session write operations. .. note:: - This function is copied from upstream, and tweaked so that our custom - :class:`TailboneSessionDataManager` will be used. + + This function appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It overrides ``zope.sqlalchemy.datamanager.join_transaction()`` + to ensure the custom :class:`TailboneSessionDataManager` is + used, and is sort of monkey-patched into the mix. """ # the upstream internals of this function has changed a little over time. # unfortunately for us, that means we must include each variant here. - if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+ - if datamanager._SESSION_STATE.get(session, None) is None: - if session.twophase: - DataManager = datamanager.TwoPhaseSessionDataManager - else: - DataManager = TailboneSessionDataManager - DataManager(session, initial_state, transaction_manager, keep_session=keep_session) - - else: # pre-1.1 - if datamanager._SESSION_STATE.get(id(session), None) is None: - if session.twophase: - DataManager = datamanager.TwoPhaseSessionDataManager - else: - DataManager = TailboneSessionDataManager - DataManager(session, initial_state, transaction_manager, keep_session=keep_session) + if datamanager._SESSION_STATE.get(session, None) is None: + if session.twophase: + DataManager = datamanager.TwoPhaseSessionDataManager + else: + DataManager = TailboneSessionDataManager + DataManager(session, initial_state, transaction_manager, keep_session=keep_session) -class ZopeTransactionExtension(datamanager.ZopeTransactionExtension): - """Record that a flush has occurred on a session's connection. This allows - the DataManager to rollback rather than commit on read only transactions. +class ZopeTransactionEvents(datamanager.ZopeTransactionEvents): + """ + Record that a flush has occurred on a session's connection. This + allows the DataManager to rollback rather than commit on read only + transactions. .. note:: - This class is copied from upstream, and tweaked so that our custom - :func:`join_transaction()` will be used. + + This class appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It subclasses + ``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but + overrides various methods to ensure the custom + :func:`join_transaction()` is called, and is sort of + monkey-patched into the mix. """ def after_begin(self, session, transaction, connection): - join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session) + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) def after_attach(self, session, instance): - join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session) + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + def join_transaction(self, session): + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) -def register(session, initial_state=datamanager.STATUS_ACTIVE, - transaction_manager=datamanager.zope_transaction.manager, keep_session=False): - """Register ZopeTransaction listener events on the - given Session or Session factory/class. +def register( + session, + initial_state=datamanager.STATUS_ACTIVE, + transaction_manager=datamanager.zope_transaction.manager, + keep_session=False, +): + """ + Register ZopeTransaction listener events on the given Session or + Session factory/class. - This function requires at least SQLAlchemy 0.7 and makes use - of the newer sqlalchemy.event package in order to register event listeners - on the given Session. + This function requires at least SQLAlchemy 0.7 and makes use of + the newer sqlalchemy.event package in order to register event + listeners on the given Session. The session argument here may be a Session class or subclass, a - sessionmaker or scoped_session instance, or a specific Session instance. - Event listening will be specific to the scope of the type of argument - passed, including specificity to its subclass as well as its identity. + sessionmaker or scoped_session instance, or a specific Session + instance. Event listening will be specific to the scope of the + type of argument passed, including specificity to its subclass as + well as its identity. .. note:: - This function is copied from upstream, and tweaked so that our custom - :class:`ZopeTransactionExtension` will be used. + + This function appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to + ensure the custom :class:`ZopeTransactionEvents` is used. """ from sqlalchemy import event - ext = ZopeTransactionExtension( - initial_state=initial_state, + ext = ZopeTransactionEvents( + initial_state=initial_state, transaction_manager=transaction_manager, keep_session=keep_session, ) @@ -160,6 +197,9 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE, event.listen(session, "after_bulk_delete", ext.after_bulk_delete) event.listen(session, "before_commit", ext.before_commit) + if datamanager.SA_GE_14: + event.listen(session, "do_orm_execute", ext.do_orm_execute) + register(Session) register(TempmonSession) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index d57aa9ac..2e582b15 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,7 +24,8 @@ Tools for displaying data diffs """ -from __future__ import unicode_literals, absolute_import +import sqlalchemy as sa +import sqlalchemy_continuum as continuum from pyramid.renderers import render from webhelpers2.html import HTML @@ -33,37 +34,41 @@ from webhelpers2.html import HTML class Diff(object): """ Core diff class. In sore need of documentation. + + You must provide the old and new data sets, and the set of + relevant fields as well, if they cannot be easily introspected. + + :param old_data: Dict of "old" data values. + + :param new_data: Dict of "old" data values. + + :param fields: Sequence of relevant field names. Note that + both data dicts are expected to have keys which match these + field names. If you do not specify the fields then they + will (hopefully) be introspected from the old or new data + sets; however this will not work if they are both empty. + + :param monospace: If true, this flag will cause the value + columns to be rendered in monospace font. This is assumed + to be helpful when comparing "raw" data values which are + shown as e.g. ``repr(val)``. + + :param enums: Optional dict of enums for use when displaying field + values. If specified, keys should be field names and values + should be enum dicts. """ - def __init__(self, old_data, new_data, columns=None, fields=None, - render_field=None, render_value=None, + def __init__(self, old_data, new_data, columns=None, fields=None, enums=None, + render_field=None, render_value=None, nature='dirty', monospace=False, extra_row_attrs=None): - """ - Constructor. You must provide the old and new data sets, and - the set of relevant fields as well, if they cannot be easily - introspected. - - :param old_data: Dict of "old" data values. - - :param new_data: Dict of "old" data values. - - :param fields: Sequence of relevant field names. Note that - both data dicts are expected to have keys which match these - field names. If you do not specify the fields then they - will (hopefully) be introspected from the old or new data - sets; however this will not work if they are both empty. - - :param monospace: If true, this flag will cause the value - columns to be rendered in monospace font. This is assumed - to be helpful when comparing "raw" data values which are - shown as e.g. ``repr(val)``. - """ self.old_data = old_data self.new_data = new_data self.columns = columns or ["field name", "old value", "new value"] self.fields = fields or self.make_fields() + self.enums = enums or {} self._render_field = render_field or self.render_field_default self.render_value = render_value or self.render_value_default + self.nature = nature self.monospace = monospace self.extra_row_attrs = extra_row_attrs @@ -90,7 +95,7 @@ class Diff(object): for the given field. May be an empty string, or a snippet of HTML attribute syntax, e.g.: - .. code-highlight:: none + .. code-block:: none class="diff" foo="bar" @@ -126,3 +131,161 @@ class Diff(object): def render_new_value(self, field): value = self.new_value(field) return self.render_value(field, value) + + +class VersionDiff(Diff): + """ + Special diff class, for use with version history views. Note that + while based on :class:`Diff`, this class uses a different + signature for the constructor. + + :param version: Reference to a Continuum version record (object). + + :param \*args: Typical usage will not require positional args + beyond the ``version`` param, in which case ``old_data`` and + ``new_data`` params will be auto-determined based on the + ``version``. But if you specify positional args then nothing + automatic is done, they are passed as-is to the parent + :class:`Diff` constructor. + + :param \*\*kwargs: Remaining kwargs are passed as-is to the + :class:`Diff` constructor. + """ + + def __init__(self, version, *args, **kwargs): + self.version = version + self.mapper = sa.inspect(continuum.parent_class(type(self.version))) + self.version_mapper = sa.inspect(type(self.version)) + self.title = kwargs.pop('title', None) + + if 'nature' not in kwargs: + if version.previous and version.operation_type == continuum.Operation.DELETE: + kwargs['nature'] = 'deleted' + elif version.previous: + kwargs['nature'] = 'dirty' + else: + kwargs['nature'] = 'new' + + if 'fields' not in kwargs: + kwargs['fields'] = self.get_default_fields() + + if not args: + old_data = {} + new_data = {} + for field in kwargs['fields']: + if version.previous: + old_data[field] = getattr(version.previous, field) + new_data[field] = getattr(version, field) + args = (old_data, new_data) + + super().__init__(*args, **kwargs) + + def get_default_fields(self): + fields = sorted(self.version_mapper.columns.keys()) + + unwanted = [ + 'transaction_id', + 'end_transaction_id', + 'operation_type', + ] + + return [field for field in fields + if field not in unwanted] + + def render_version_value(self, field, value, version): + """ + Render the cell value text for the given version/field info. + + Note that this method is used to render both sides of the diff + (before and after values). + + :param field: Name of the field, as string. + + :param value: Raw value for the field, as obtained from ``version``. + + :param version: Reference to the Continuum version object. + + :returns: Rendered text as string, or ``None``. + """ + text = HTML.tag('span', c=[repr(value)], + style='font-family: monospace;') + + # assume the enum display is all we need, if enum exists for the field + if field in self.enums: + + # but skip the enum display if None + display = self.enums[field].get(value) + if display is None and value is None: + return text + + # otherwise show enum display to the right of raw value + display = self.enums[field].get(value, str(value)) + return HTML.tag('span', c=[ + text, + HTML.tag('span', c=[display], + style='margin-left: 2rem; font-style: italic; font-weight: bold;'), + ]) + + # next we look for a relationship and may render the foreign object + for prop in self.mapper.relationships: + if prop.uselist: + continue + + for col in prop.local_columns: + if col.name != field: + continue + + if not hasattr(version, prop.key): + continue + + if col in self.mapper.primary_key: + continue + + ref = getattr(version, prop.key) + if ref: + ref = getattr(ref, 'version_parent', None) + if ref: + return HTML.tag('span', c=[ + text, + HTML.tag('span', c=[str(ref)], + style='margin-left: 2rem; font-style: italic; font-weight: bold;'), + ]) + + return text + + def render_old_value(self, field): + if self.nature == 'new': + return '' + value = self.old_value(field) + return self.render_version_value(field, value, self.version.previous) + + def render_new_value(self, field): + if self.nature == 'deleted': + return '' + value = self.new_value(field) + return self.render_version_value(field, value, self.version) + + def as_struct(self): + values = {} + for field in self.fields: + values[field] = {'before': self.render_old_value(field), + 'after': self.render_new_value(field)} + + operation = None + if self.version.operation_type == continuum.Operation.INSERT: + operation = 'INSERT' + elif self.version.operation_type == continuum.Operation.UPDATE: + operation = 'UPDATE' + elif self.version.operation_type == continuum.Operation.DELETE: + operation = 'DELETE' + else: + operation = self.version.operation_type + + return { + 'key': id(self.version), + 'model_title': self.title, + 'operation': operation, + 'diff_class': self.nature, + 'fields': self.fields, + 'values': values, + } diff --git a/tailbone/exceptions.py b/tailbone/exceptions.py index beea1366..3468562a 100644 --- a/tailbone/exceptions.py +++ b/tailbone/exceptions.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Exceptions """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.exceptions import RattailError @@ -37,7 +33,6 @@ class TailboneError(RattailError): """ -@six.python_2_unicode_compatible class TailboneJSONFieldError(TailboneError): """ Error raised when JSON serialization of a form field results in an error. diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py index a368f2d1..34b34a6c 100644 --- a/tailbone/forms/__init__.py +++ b/tailbone/forms/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,7 @@ Forms Library """ -from __future__ import unicode_literals, absolute_import - -from . import types +# nb. import widgets before types, b/c types may refer to widgets from . import widgets +from . import types from .core import Form, SimpleFileImport diff --git a/tailbone/forms/common.py b/tailbone/forms/common.py index 4d58b943..6183d17f 100644 --- a/tailbone/forms/common.py +++ b/tailbone/forms/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Common Forms """ -from __future__ import unicode_literals, absolute_import - from rattail.db import model import colander @@ -35,7 +33,7 @@ import colander def validate_user(node, kw): session = kw['session'] def validate(node, value): - user = session.query(model.User).get(value) + user = session.get(model.User, value) if not user: raise colander.Invalid(node, "User not found") return user.uuid diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index bf508a6f..4024557b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,19 +24,18 @@ Forms Core """ -from __future__ import unicode_literals, absolute_import - +import hashlib import json import logging +import warnings +from collections import OrderedDict -import six import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY +from wuttjamaican.util import UNSPECIFIED -from rattail.time import localtime -from rattail.util import prettify, pretty_boolean, pretty_quantity -from rattail.core import UNSPECIFIED +from rattail.util import pretty_boolean from rattail.db.util import get_fieldnames import colander @@ -48,9 +47,14 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from tailbone.util import raw_datetime, get_form_data -from . import types -from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget +from wuttaweb.util import FieldList, get_form_data, make_json_safe + +from tailbone.db import Session +from tailbone.util import raw_datetime, render_markdown +from tailbone.forms import types +from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, + JQueryDateWidget, JQueryTimeWidget, + FileUploadWidget, MultiFileUploadWidget) from tailbone.exceptions import TailboneJSONFieldError @@ -222,7 +226,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode): if excludes: overrides['excludes'] = excludes - return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides) + return super().get_schema_from_relationship(prop, overrides) def dictify(self, obj): """ Return a dictified version of `obj` using schema information. @@ -231,7 +235,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode): This method was copied from upstream and modified to add automatic handling of "association proxy" fields. """ - dict_ = super(CustomSchemaNode, self).dictify(obj) + dict_ = super().dictify(obj) for node in self: name = node.name @@ -324,7 +328,7 @@ class Form(object): """ Base class for all forms. """ - save_label = "Save" + save_label = "Submit" update_label = "Save" show_cancel = True auto_disable = True @@ -335,8 +339,12 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form', - vuejs_field_converters={}, + action_url=None, cancel_url=None, + vue_tagname=None, + vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={}, + # TODO: ugh this is getting out hand! + can_edit_help=False, edit_help_url=None, route_prefix=None, + **kwargs ): self.fields = None if fields is not None: @@ -344,6 +352,7 @@ class Form(object): self.schema = schema if self.fields is None and self.schema: self.set_fields([f.name for f in self.schema]) + self.grouping = None self.request = request self.readonly = readonly self.readonly_fields = set(readonly_fields or []) @@ -369,21 +378,83 @@ class Form(object): self.validators = validators or {} self.required = required or {} self.helptext = helptext or {} + self.dynamic_helptext = {} self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url - self.use_buefy = use_buefy - self.component = component + + # vue_tagname + self.vue_tagname = vue_tagname + if not self.vue_tagname and kwargs.get('component'): + warnings.warn("component kwarg is deprecated for Form(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + self.vue_tagname = kwargs['component'] + if not self.vue_tagname: + self.vue_tagname = 'tailbone-form' + + self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} + self.json_data = json_data or {} + self.included_templates = included_templates or {} + self.can_edit_help = can_edit_help + self.edit_help_url = edit_help_url + self.route_prefix = route_prefix + + self.button_icon_submit = kwargs.get('button_icon_submit', 'save') def __iter__(self): return iter(self.fields) @property - def component_studly(self): - words = self.component.split('-') + def vue_component(self): + """ + String name for the Vue component, e.g. ``'TailboneGrid'``. + + This is a generated value based on :attr:`vue_tagname`. + """ + words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words]) + @property + def component(self): + """ + DEPRECATED - use :attr:`vue_tagname` instead. + """ + warnings.warn("Form.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + + @property + def component_studly(self): + """ + DEPRECATED - use :attr:`vue_component` instead. + """ + warnings.warn("Form.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component + + def get_button_label_submit(self): + """ """ + if hasattr(self, '_button_label_submit'): + return self._button_label_submit + + label = getattr(self, 'submit_label', None) + if label: + return label + + return self.save_label + + def set_button_label_submit(self, value): + """ """ + self._button_label_submit = value + + # wutta compat + button_label_submit = property(get_button_label_submit, + set_button_label_submit) + def __contains__(self, item): return item in self.fields @@ -400,6 +471,9 @@ class Form(object): return get_fieldnames(self.request.rattail_config, self.model_class, columns=True, proxies=True, relations=True) + def set_grouping(self, items): + self.grouping = OrderedDict(items) + def make_renderers(self): """ Return a default set of field renderers, based on :attr:`model_class`. @@ -556,7 +630,9 @@ class Form(object): self.schema[key].title = label def get_label(self, key): - return self.labels.get(key, prettify(key)) + config = self.request.rattail_config + app = config.get_app() + return self.labels.get(key, app.make_title(key)) def set_readonly(self, key, readonly=True): if readonly: @@ -573,9 +649,23 @@ class Form(object): node = colander.SchemaNode(nodeinfo, **kwargs) self.nodes[key] = node + # must explicitly replace node, if we already have a schema + if self.schema: + self.schema[key] = node + def set_type(self, key, type_, **kwargs): + if type_ == 'datetime': self.set_renderer(key, self.render_datetime) + + elif type_ == 'datetime_falafel': + self.set_renderer(key, self.render_datetime) + self.set_node(key, types.FalafelDateTime(request=self.request)) + if kwargs.get('helptext'): + app = self.request.rattail_config.get_app() + timezone = app.get_timezone() + self.set_helptext(key, f"NOTE: all times are local to {timezone}") + elif type_ == 'datetime_local': self.set_renderer(key, self.render_datetime_local) elif type_ == 'date_plain': @@ -584,9 +674,14 @@ class Form(object): # TODO: is this safe / a good idea? # self.set_node(key, colander.Date()) self.set_widget(key, JQueryDateWidget()) + elif type_ == 'time_jquery': self.set_node(key, types.JQueryTime()) self.set_widget(key, JQueryTimeWidget()) + + elif type_ == 'time_falafel': + self.set_node(key, types.FalafelTime(request=self.request)) + elif type_ == 'duration': self.set_renderer(key, self.render_duration) elif type_ == 'boolean': @@ -613,17 +708,40 @@ class Form(object): self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) elif type_ == 'file': tmpstore = SessionFileUploadTempStore(self.request) - kw = {'widget': dfwidget.FileUploadWidget(tmpstore), + kw = {'widget': FileUploadWidget(tmpstore, request=self.request), 'title': self.get_label(key)} if 'required' in kwargs and not kwargs['required']: kw['missing'] = colander.null self.set_node(key, colander.SchemaNode(deform.FileData(), **kw)) - # must explicitly replace node, if we already have a schema - if self.schema: - self.schema[key] = self.nodes[key] + elif type_ == 'multi_file': + tmpstore = SessionFileUploadTempStore(self.request) + file_node = colander.SchemaNode(deform.FileData(), + name='upload') + + kw = {'name': key, + 'title': self.get_label(key), + 'widget': MultiFileUploadWidget(tmpstore)} + # if 'required' in kwargs and not kwargs['required']: + # kw['missing'] = colander.null + if kwargs.get('validate_unique'): + kw['validator'] = self.validate_multiple_files_unique + files_node = colander.SequenceSchema(file_node, **kw) + self.set_node(key, files_node) else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) + def validate_multiple_files_unique(self, node, value): + + # get SHA256 hash for each file; error if duplicates encountered + hashes = {} + for fileinfo in value: + fp = fileinfo['fp'] + fp.seek(0) + filehash = hashlib.sha256(fp.read()).hexdigest() + if filehash in hashes: + node.raise_invalid(f"Duplicate file detected: {fileinfo['filename']}") + hashes[filehash] = fileinfo + def set_enum(self, key, enum, empty=None): if enum: self.enums[key] = enum @@ -687,9 +805,8 @@ class Form(object): case the validator pertains to the form at large instead of one of the fields. - TODO: what should the validator look like? - - :param validator: Callable validator for the node. + :param validator: Callable which accepts ``(node, value)`` + args. """ self.validators[key] = validator @@ -711,11 +828,16 @@ class Form(object): """ self.defaults[key] = value - def set_helptext(self, key, value): + def set_helptext(self, key, value, dynamic=False): """ Set the help text for a given field. """ - self.helptext[key] = value + # nb. must avoid newlines, they cause some weird "blank page" error?! + self.helptext[key] = value.replace('\n', ' ') + if value and dynamic: + self.dynamic_helptext[key] = True + else: + self.dynamic_helptext.pop(key, None) def has_helptext(self, key): """ @@ -735,15 +857,15 @@ class Form(object): def set_vuejs_field_converter(self, field, converter): self.vuejs_field_converters[field] = converter - def render(self, template=None, **kwargs): - if not template: - if self.readonly and not self.use_buefy: - template = '/forms/form_readonly.mako' - else: - template = '/forms/form.mako' - context = kwargs - context['form'] = self - return render(template, context) + def render(self, **kwargs): + warnings.warn("Form.render() is deprecated (for now?); " + "please use Form.render_deform() instead", + DeprecationWarning, stacklevel=2) + return self.render_deform(**kwargs) + + def get_deform(self): + """ """ + return self.make_deform_form() def make_deform_form(self): if not hasattr(self, 'deform_form'): @@ -783,31 +905,35 @@ class Form(object): return self.deform_form + def render_vue_template(self, template='/forms/deform.mako', **context): + """ """ + output = self.render_deform(template=template, **context) + return HTML.literal(output) + def render_deform(self, dform=None, template=None, **kwargs): if not template: - if self.use_buefy: - template = '/forms/deform_buefy.mako' - else: - template = '/forms/deform.mako' + template = '/forms/deform.mako' if dform is None: dform = self.make_deform_form() # TODO: would perhaps be nice to leverage deform's default rendering # someday..? i.e. using Chameleon *.pt templates - # return form.render() + # return dform.render() context = kwargs context['form'] = self context['dform'] = dform + context.setdefault('can_edit_help', self.can_edit_help) + if context['can_edit_help']: + context.setdefault('edit_help_url', self.edit_help_url) + context['field_labels'] = self.get_field_labels() + context['field_markdowns'] = self.get_field_markdowns() context.setdefault('form_kwargs', {}) # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: - if self.use_buefy: - context['form_kwargs'].setdefault('ref', self.component_studly) - context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) - else: - context['form_kwargs']['class_'] = 'autodisable' + context['form_kwargs'].setdefault('ref', self.vue_component) + context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component) if self.focus_spec: context['form_kwargs']['data-focus'] = self.focus_spec context['request'] = self.request @@ -815,6 +941,36 @@ class Form(object): context['render_field_readonly'] = self.render_field_readonly return render(template, context) + def get_field_labels(self): + return dict([(field, self.get_label(field)) + for field in self]) + + def get_field_markdowns(self, session=None): + app = self.request.rattail_config.get_app() + model = app.model + session = session or Session() + + if not hasattr(self, 'field_markdowns'): + infos = session.query(model.TailboneFieldInfo)\ + .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\ + .all() + self.field_markdowns = dict([(info.field_name, info.markdown_text) + for info in infos]) + + return self.field_markdowns + + def get_vue_field_value(self, key): + """ """ + if key not in self.fields: + return + + dform = self.get_deform() + if key not in dform: + return + + field = dform[key] + return make_json_safe(field.cstruct) + def get_vuejs_model_value(self, field): """ This method must return "raw" JS which will be assigned as the initial @@ -826,25 +982,38 @@ class Form(object): value = convert(field.cstruct) return json.dumps(value) - if isinstance(field.schema.typ, deform.FileData): - # TODO: we used to always/only return 'null' here but hopefully - # this also works, to show existing filename when present - if field.cstruct and field.cstruct['filename']: - return json.dumps({'name': field.cstruct['filename']}) - return 'null' - if isinstance(field.schema.typ, colander.Set): if field.cstruct is colander.null: return '[]' - if field.cstruct is colander.null: - return 'null' - try: - return json.dumps(field.cstruct) + return self.jsonify_value(field.cstruct) except Exception as error: raise TailboneJSONFieldError(field.name, error) + def jsonify_value(self, value): + """ + Take a Python value and convert to JSON + """ + if value is colander.null: + return 'null' + + if isinstance(value, dfwidget.filedict): + # TODO: we used to always/only return 'null' here but hopefully + # this also works, to show existing filename when present + if value and value['filename']: + return json.dumps({'name': value['filename']}) + return 'null' + + elif isinstance(value, list) and all([isinstance(f, dfwidget.filedict) + for f in value]): + return json.dumps([{'name': f['filename']} + for f in value]) + + app = self.request.rattail_config.get_app() + value = app.json_friendly(value) + return json.dumps(value) + def get_error_messages(self, field): if field.error: return field.error.messages() @@ -865,68 +1034,208 @@ class Form(object): return False return True - def render_buefy_field(self, fieldname, bfield_attrs={}): + def set_vuejs_component_kwargs(self, **kwargs): + self.vuejs_component_kwargs.update(kwargs) + + def render_vue_tag(self, **kwargs): + """ """ + return self.render_vuejs_component(**kwargs) + + def render_vuejs_component(self, **kwargs): """ - Render the given field in a Buefy-compatible way. Note that - this is meant to render *editable* fields, i.e. showing a - widget, unless the field input is hidden. In other words it's - not for "readonly" fields. + Render the Vue.js component HTML for the form. + + Most typically this is something like: + + .. code-block:: html + + + + """ + kw = dict(self.vuejs_component_kwargs) + kw.update(kwargs) + if self.can_edit_help: + kw.setdefault(':configure-fields-help', 'configureFieldsHelp') + return HTML.tag(self.vue_tagname, **kw) + + def set_json_data(self, key, value): + """ + Establish a data value for use in client-side JS. This value + will be JSON-encoded and made available to the + `` 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 ```` + wrapper. Note that this is meant to render *editable* fields, + i.e. showing a widget, unless the field input is hidden. In + other words it's not for "readonly" fields. """ dform = self.make_deform_form() - field = dform[fieldname] + field = dform[fieldname] if fieldname in dform else None + + include = bool(field) + if self.readonly or (not field and fieldname in self.readonly_fields): + include = True + if not include: + return if self.field_visible(fieldname): + label = self.get_label(fieldname) + markdowns = self.get_field_markdowns(session=session) # these attrs will be for the (*not* the widget) attrs = { ':horizontal': 'true', - 'label': self.get_label(fieldname), } # add some magic for file input fields - if isinstance(field.schema.typ, deform.FileData): + if field and isinstance(field.schema.typ, deform.FileData): attrs['class_'] = 'file' - # show helptext if present - if self.has_helptext(fieldname): - attrs['message'] = self.render_helptext(fieldname) + # next we will build array of messages to display..some + # fields always show a "helptext" msg, and some may have + # validation errors.. + field_type = None + messages = [] # show errors if present - error_messages = self.get_error_messages(field) + error_messages = self.get_error_messages(field) if field else None if error_messages: + field_type = 'is-danger' + messages.extend(error_messages) - # TODO: this surely can't be what we ought to do - # here..? seems like we must pass JS but not JSON, - # sort of, so we custom-write the JS code to ensure - # single instead of double quotes delimit strings - # within the code. - message = '[{}]'.format(', '.join([ - "'{}'".format(msg.replace("'", r"\'")) - for msg in error_messages])) + # show helptext if present + # TODO: older logic did this only if field was *not* + # readonly, perhaps should add that back.. + if self.has_helptext(fieldname): + messages.append(self.render_helptext(fieldname)) - attrs.update({ - 'type': 'is-danger', - ':message': message, - }) + # ..okay now we can declare the field messages and type + if field_type: + attrs['type'] = field_type + if messages: + if len(messages) == 1: + msg = messages[0] + if msg.startswith('`') and msg.endswith('`'): + attrs[':message'] = msg + else: + attrs['message'] = msg + else: + # nb. must pass an array as JSON string + attrs[':message'] = '[{}]'.format(', '.join([ + "'{}'".format(msg.replace("'", r"\'")) + for msg in messages])) # merge anything caller provided attrs.update(bfield_attrs) # render the field widget or whatever - html = field.serialize(use_buefy=True, - **self.get_renderer_kwargs(fieldname)) - # TODO: why do we not get HTML literal from serialize() ? - html = HTML.literal(html) + if self.readonly or fieldname in self.readonly_fields: + html = self.render_field_value(fieldname) or HTML.tag('span') + if type(html) is str: + html = HTML.tag('span', c=[html]) + elif field: + html = field.serialize(**self.get_renderer_kwargs(fieldname)) + html = HTML.literal(html) + + # may need a complex label + label_contents = [label] + + # add 'help' icon/tooltip if defined + if markdowns.get(fieldname): + icon = HTML.tag('b-icon', size='is-small', pack='fas', + icon='question-circle') + tooltip = render_markdown(markdowns[fieldname]) + + # nb. must apply hack to get