diff --git a/.gitignore b/.gitignore index b9f93bc3..b3006f90 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ -rattail.pyramid.egg-info +*~ +*.pyc +.coverage +.tox/ +dist/ +docs/_build/ +htmlcov/ +Tailbone.egg-info/ diff --git a/.hgignore b/.hgignore deleted file mode 100644 index 120868d7..00000000 --- a/.hgignore +++ /dev/null @@ -1,2 +0,0 @@ -syntax:glob -rattail.pyramid.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/CHANGES.txt b/CHANGES.txt deleted file mode 100644 index c3c22d73..00000000 --- a/CHANGES.txt +++ /dev/null @@ -1,62 +0,0 @@ - -0.3a6 ------ - -- Add Vendor CRUD. - -- Add Brand views. - -0.3a5 ------ - -- Added support for GPC data type. - -- Added eager import of ``rattail.sil`` in ``before_render`` hook. - -- Removed ``rattail.pyramid.util`` module. - -- Added initial batch support: views, templates, creation from Product grid. - -- Added support for ``rattail.LabelProfile`` class. - -- Improved Product grid to include filter/sort on Vendor. - -- Cleaned up dependencies. - -- Added ``rattail.pyramid.includeme()``. - -- Added ``CustomerGroup`` CRUD view (read only). - -- Added hot links to ``Customer`` CRUD view. - -- Added ``Store`` index, CRUD views. - -- Updated ``rattail.pyramid.views.includeme()``. - -- Added ``email_preference`` to ``Customer`` CRUD. - -0.3a4 ------ - -- Update grid and CRUD views per changes in ``edbob``. - -0.3a3 ------ - -- Add price field renderers. - -- Add/tweak lots of views for database models. - -- Add label printing to product list view. - -- Add (some of) ``Product`` CRUD. - -0.3a2 ------ - -- Refactor category views. - -0.3a1 ------ - -- Initial port to Rattail v0.3. diff --git a/COPYING.txt b/COPYING.txt new file mode 100644 index 00000000..94a9ed02 --- /dev/null +++ b/COPYING.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program 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. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/MANIFEST.in b/MANIFEST.in index a5c09028..a3d57f93 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,18 @@ -include *.txt *.ini *.cfg *.rst -recursive-include rattail/pyramid *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml + +include *.txt +include *.rst +include *.py + +include tailbone/static/robots.txt +recursive-include tailbone/static *.js +recursive-include tailbone/static *.css +recursive-include tailbone/static *.png +recursive-include tailbone/static *.jpg +recursive-include tailbone/static *.gif +recursive-include tailbone/static *.ico + +recursive-include tailbone/static/files * + +recursive-include tailbone/templates *.mako +recursive-include tailbone/templates *.pt +recursive-include tailbone/reports *.mako diff --git a/README.md b/README.md new file mode 100644 index 00000000..74c007f6 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ + +# 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](http://rattailproject.org/) for more +information. diff --git a/README.txt b/README.txt deleted file mode 100644 index c71ce1e0..00000000 --- a/README.txt +++ /dev/null @@ -1,11 +0,0 @@ - -rattail.pyramid -=============== - -Rattail is a retail software framework based on `edbob `_, -and released under the GNU Affero General Public License. - -This package contains Pyramid views, etc., for managing a Rattail system. - -Please see Rattail's `home page `_ for more -information. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..ea41334a --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Tailbone.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Tailbone.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Tailbone" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Tailbone" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/OLDCHANGES.rst b/docs/OLDCHANGES.rst new file mode 100644 index 00000000..0a802f40 --- /dev/null +++ b/docs/OLDCHANGES.rst @@ -0,0 +1,7539 @@ + +CHANGELOG +========= + +NB. this file contains "old" release notes only. for newer releases +see the `CHANGELOG.md` file in the source root folder. + + +0.9.96 (2024-04-25) +------------------- + +* Remove unused code for ``webhelpers2_grid``. + +* Rename setting for custom user css (remove "buefy"). + +* Fix permission checks for root user with pyramid 2.x. + +* Cleanup grid/filters logic a bit. + +* Use normal (not checkbox) button for grid filters. + +* Tweak icon for Download Results button. + +* Use v-model to track selection etc. for download results fields. + +* Allow deleting rows from executed batches. + + +0.9.95 (2024-04-19) +------------------- + +* Fix ASGI websockets when serving on sub-path under site root. + +* Fix raw query to avoid SQLAlchemy 2.x warnings. + +* Remove config "style" from appinfo page. + + +0.9.94 (2024-04-16) +------------------- + +* Fix master template bug when no form in context. + + +0.9.93 (2024-04-16) +------------------- + +* Improve form support for view supplements. + +* Prevent multi-click for grid filters "Save Defaults" button. + +* Fix typo when getting app instance. + + +0.9.92 (2024-04-16) +------------------- + +* Escape underscore char for "contains" query filter. + +* Rename custom ``user_css`` context. + +* Add support for Pyramid 2.x; new security policy. + + +0.9.91 (2024-04-15) +------------------- + +* Avoid uncaught error when updating order batch row quantities. + +* Try to return JSON error when receiving API call fails. + +* Avoid error for tax field when creating new department. + +* Show toast msg instead of silent error, when grid fetch fails. + +* Remove most references to "buefy" name in class methods, template + filenames etc. + + +0.9.90 (2024-04-01) +------------------- + +* Add basic CRUD for Person "preferred first name". + + +0.9.89 (2024-03-27) +------------------- + +* Fix bulk-delete rows for import/export batch. + + +0.9.88 (2024-03-26) +------------------- + +* Update some SQLAlchemy logic per upcoming 2.0 changes. + + +0.9.87 (2023-12-26) +------------------- + +* Auto-disable submit button for login form. + +* Hide single invoice file field for multi-invoice receiving batch. + +* Use common logic to render invoice total for receiving. + +* Expose default custorder discount for Departments. + + +0.9.86 (2023-12-12) +------------------- + +* Use ``ltrim(rtrim())`` instead of just ``trim()`` in grid filters. + + +0.9.85 (2023-12-01) +------------------- + +* Use clientele handler to populate customer dropdown widget. + + +0.9.84 (2023-11-30) +------------------- + +* Provide a way to show enum display text for some version diff fields. + + +0.9.83 (2023-11-30) +------------------- + +* Avoid error when editing a department. + + +0.9.82 (2023-11-19) +------------------- + +* Fix DB picker, theme picker per Buefy conventions. + + +0.9.81 (2023-11-15) +------------------- + +* Log warning instead of error for batch population error. + +* Remove reference to ``pytz`` library. + +* Avoid outright error if user scans barcode for inventory count. + + +0.9.80 (2023-11-05) +------------------- + +* Expose status code for equity payments. + + +0.9.79 (2023-11-01) +------------------- + +* Add button to confirm all costs for receiving. + + +0.9.78 (2023-11-01) +------------------- + +* Use shared logic to get batch handler. + +* Fix config key for default themes list. + + +0.9.77 (2023-11-01) +------------------- + +* Encode values for "between" query filter. + +* Avoid error when rendering version diff. + + +0.9.76 (2023-11-01) +------------------- + +* Fix missing import. + + +0.9.75 (2023-11-01) +------------------- + +* Add deprecation warnings for ambgiguous config keys. + + +0.9.74 (2023-10-30) +------------------- + +* Log warning / avoid error if email profile can't be normalized. + + +0.9.73 (2023-10-29) +------------------- + +* Add way to "ignore" a pending product. + +* Tweak param docs for ``Form.set_validator()``. + +* Remove unused "simple menus" module approach. + + +0.9.72 (2023-10-26) +------------------- + +* Use product lookup component for "resolve pending product" tool. + + +0.9.71 (2023-10-25) +------------------- + +* Fix bug when editing vendor. + +* Show user warning if "add item to custorder" fails. + +* Allow pending product fields to be required, for new custorder. + +* Add price confirm prompt when adding unknown item to custorder. + +* Use ```` for theme picker. + +* Add ``column_only`` kwarg for ``Grid.set_label()`` method. + +* Do not show profile buttons for inactive customer shoppers. + +* Add separate perm for making new custorder for unknown product. + +* Expand the "product lookup" component to include autocomplete. + + +0.9.70 (2023-10-24) +------------------- + +* Fix config file priority for display, and batch subprocess commands. + + +0.9.69 (2023-10-24) +------------------- + +* Allow override of version diff for master views. + +* No need to configure logging. + + +0.9.68 (2023-10-23) +------------------- + +* Expose more permissions for POS. + +* Fix order xlsx download if missing order date. + +* Replace dropdowns with autocomplete, for "find principals by perm". + +* Use ``Grid.make_sorter()`` instead of legacy code. + +* Avoid "None" when rendering product UOM field. + +* Fix default grid filter when "local" date times are involved. + +* Expose new fields for POS batch/row. + +* Remove sorter for "Credits?" column in purchasing batch row grid. + +* Add validation to prevent duplicate files for multi-invoice receiving. + +* Include invoice number for receiving batch row API. + +* Show food stamp tender info for POS batch. + +* Stop using sa-filters for basic grid sorting. + + +0.9.67 (2023-10-12) +------------------- + +* Fix grid sorting when column key/name differ. + +* Expose department tax, FS flag. + +* Add permission for testing error handling at POS. + +* Add some awareness of suspend/resume for POS batch. + +* Fix version child classes for Customers view. + + +0.9.66 (2023-10-11) +------------------- + +* Make grid JS ``loadAsyncData()`` method truly async. + +* Add support for multi-column grid sorting. + +* Add smarts to show display text for some version diff fields. + +* Allow null for FalafelDateTime form fields. + +* Show full version history within the "view" page. + +* Use autocomplete instead of dropdown for grid "add filter". + + +0.9.65 (2023-10-07) +------------------- + +* Avoid deprecated logic for fetching vendor contact email/phone. + +* Add "mark complete" button for inventory batch row entry page. + +* Expose tender ref in POS batch rows; new tender flags. + +* Improve views for taxes, esp. in POS batches. + + +0.9.64 (2023-10-06) +------------------- + +* Fix bug for param helptext in New Report page. + + +0.9.63 (2023-10-06) +------------------- + +* Fix CRUD pages for tempmon clients, probes. + +* Fix bug in POS batch view. + +* Expose permissions for POS, if so configured. + + +0.9.62 (2023-10-04) +------------------- + +* Avoid deprecated ``pretty_hours()`` function. + +* Improve master view ``oneoff_import()`` method. + + +0.9.61 (2023-10-04) +------------------- + +* Use enum to display ``POS_ROW_TYPE``. + +* Expose cash-back flags for tenders. + +* Re-work FalafelDateTime logic a bit. + + +0.9.60 (2023-10-01) +------------------- + +* Do not allow executing custorder if no customer is set. + +* Add clone support for POS batches. + +* Expose views for tenders, more columns for POS batch/rows. + +* Tidy up logic for vendor filtering in products grid. + +* Add support for void rows in POS batch. + + +0.9.59 (2023-09-25) +------------------- + +* Add custom form type/widget for time fields. + + +0.9.58 (2023-09-25) +------------------- + +* Expose POS batch views as "typical". + + +0.9.57 (2023-09-24) +------------------- + +* Show yesterday by default for Trainwreck if so configured. + +* Add ``remove_sorter()`` method for grids. + +* Show "true" (calculated) equity total in members grid. + +* Add basic views for POS batches. + +* Show customer for POS batches. + +* Use header button instead of link for "touch" instance. + + +0.9.56 (2023-09-19) +------------------- + +* Add link to vendor name for receiving batches grid. + +* Prevent catalog/invoice cost edits if receiving batch is complete. + +* Use small text input for receiving cost editor fields. + +* Show catalog/invoice costs as 2-decimal currency in receiving. + + +0.9.55 (2023-09-18) +------------------- + +* Show user warning if receive quick lookup fails. + +* Fix bug for new receiving from scratch via API. + + +0.9.54 (2023-09-17) +------------------- + +* Add "falafel" custom date/time field type and widget. + +* Avoid error when history has blanks for ordering worksheet. + +* Include PO number for receiving batch details via API. + +* Tweaks to improve handling of "missing" items for receiving. + + +0.9.53 (2023-09-16) +------------------- + +* Make member key field readonly when viewing equity payment. + + +0.9.52 (2023-09-15) +------------------- + +* Add basic feature for "grid totals". + + +0.9.51 (2023-09-15) +------------------- + +* Tweak default field list for batch views. + +* Add ``get_rattail_app()`` method for view supplements. + + +0.9.50 (2023-09-12) +------------------- + +* Avoid legacy logic for ``Customer.people`` schema. + +* Show events instead of notes, in field subgrid for custorder item. + + +0.9.49 (2023-09-11) +------------------- + +* Add custom hook for grid "apply filters". + +* Use common POST logic for submitting new customer order. + +* Optionally configure SQLAlchemy Session with ``future=True``. + +* Show related customer orders for Pending Product view. + +* Set stacklevel for all deprecation warnings. + +* Add support for toggling custorder item "flagged". + +* Add support for "mark received" when viewing custorder item. + +* Misc. improvements for custorder views. + + +0.9.48 (2023-09-08) +------------------- + +* Add grid link for equity payment description. + +* Fix msg body display, download link for email bounces. + +* Fix member key display for equity payment form. + + +0.9.47 (2023-09-07) +------------------- + +* Fallback to None when getting values for merge preview. + + +0.9.46 (2023-09-07) +------------------- + +* Improve display for member equity payments. + + +0.9.45 (2023-09-02) +------------------- + +* Add grid filter type for BigInteger columns. + +* Add products API route to fetch label profiles for use w/ printing. + +* Tweaks for cost editing within a receiving batch. + + +0.9.44 (2023-08-31) +------------------- + +* Avoid deprecated ``User.email_address`` property. + +* Preserve URL hash when redirecting in grid "reset to defaults". + + +0.9.43 (2023-08-30) +------------------- + +* Let "new product" batch override type-2 UPC lookup behavior. + + +0.9.42 (2023-08-29) +------------------- + +* When bulk-deleting, skip objects which are not "deletable". + +* Declare "from PO" receiving workflow if applicable, in API. + +* Auto-select text when editing costs for receiving. + +* Include shopper history from parent customer account perspective. + +* Link to product record, for New Product batch row. + +* Fix profile history to show when a CustomerShopperHistory is deleted. + +* Fairly massive overhaul of the Profile view; standardize tabs etc.. + +* Add support for "missing" credit in mobile receiving. + + +0.9.41 (2023-08-08) +------------------- + +* Add common logic to validate employee reference field. + +* Fix HTML rendering for UOM choice options. + +* Fix custom cell click handlers in main buefy grid tables. + + +0.9.40 (2023-08-03) +------------------- + +* Make system key searchable for problem report grid. + + +0.9.39 (2023-07-15) +------------------- + +* Show invoice number for each row in receiving. + +* Tweak display options for tempmon probe readings graph. + + +0.9.38 (2023-07-07) +------------------- + +* Optimize "auto-receive" batch process. + + +0.9.37 (2023-07-03) +------------------- + +* Avoid deprecated product key field getter. + +* Allow "arbitrary" PO attachment to purchase batch. + + +0.9.36 (2023-06-20) +------------------- + +* Include user "active" flag in profile view context. + + +0.9.35 (2023-06-20) +------------------- + +* Add views etc. for member equity payments. + +* Improve merge support for records with no uuid. + +* Turn on quickie person search for CustomerShopper views. + + +0.9.34 (2023-06-17) +------------------- + +* Add basic Shopper tab for profile view. + +* Cleanup some wording in profile view template. + +* Tweak ``SimpleRequestMixin`` to not rely on ``response.data.ok``. + +* Add support for Notes tab in profile view. + +* Add basic support for Person quickie lookup. + +* Hide unwanted revisions for CustomerPerson etc. + +* Fix some things for viewing a member. + + +0.9.33 (2023-06-16) +------------------- + +* Update usage of app handler per upstream changes. + + +0.9.32 (2023-06-16) +------------------- + +* Fix grid filter bug when switching from 'equal' to 'between' verbs. + +* Add users context data for profile view. + +* Join the Person model for Customers grid differently based on config. + + +0.9.31 (2023-06-15) +------------------- + +* Prefer account holder, shoppers over legacy ``Customers.people``. + + +0.9.30 (2023-06-12) +------------------- + +* Add basic support for exposing ``Customer.shoppers``. + +* Move "view history" and related buttons, for person profile view. + +* Consider vendor catalog batch views "typical". + +* Let external customer link buttons be more dynamic, for profile view. + +* Add options for grid results to link straight to Profile view. + +* Change label for Member.person to "Account Holder". + + +0.9.29 (2023-06-06) +------------------- + +* Add "typical" view config, for e.g. Theo and the like. + +* Add customer number filter for People grid. + +* Tweak logic for ``MasterView.get_action_route_kwargs()``. + +* Add "touch" support for Members. + +* Add support for "configured customer/member key". + +* Use *actual* current URL for user feedback msg. + +* Remove old/unused feedback templates. + +* Add basic support for membership types. + +* Add support for version history in person profile view. + + +0.9.28 (2023-06-02) +------------------- + +* Expose mail handler and template paths in email config page. + + +0.9.27 (2023-06-01) +------------------- + +* Share some code for validating vendor field. + +* Save datasync config with new keys, per RattailConfiguration. + + +0.9.26 (2023-05-25) +------------------- + +* Prevent bug in upgrade diff for empty new version. + +* Expose basic way to send test email. + +* Avoid error when filter params not valid. + +* Tweak byjove project generator form. + +* Define essential views for API. + + +0.9.25 (2023-05-18) +------------------- + +* Add initial swagger.json endpoint for API. + +* Add workaround for "share grid link" on insecure sites. + + +0.9.24 (2023-05-16) +------------------- + +* Replace ``setup.py`` contents with ``setup.cfg``. + +* Prevent error in old product search logic. + + +0.9.23 (2023-05-15) +------------------- + +* Get rid of ``newstyle`` flag for ``Form.validate()`` method. + +* Add basic support for managing, and accepting API tokens. + + +0.9.22 (2023-05-13) +------------------- + +* Tweak button wording in "find role by perm" form. + +* Warn user if DB not up to date, in new table wizard. + + +0.9.21 (2023-05-10) +------------------- + +* Move row delete check logic for receiving to batch handler. + + +0.9.20 (2023-05-09) +------------------- + +* Add form config for generating 'shopfoo' projects. + +* Misc. tweaks for "run import job" form. + + +0.9.19 (2023-05-05) +------------------- + +* Massive overhaul of "generate project" feature. + +* Include project views by default, in "essential" views. + + +0.9.18 (2023-05-03) +------------------- + +* Avoid error if tempmon probe has invalid status. + +* Expose, honor the ``prevent_password_change`` flag for Users. + + +0.9.17 (2023-04-17) +------------------- + +* Allow bulk-delete for products grid. + +* Improve global menu search behavior for multiple terms. + + +0.9.16 (2023-03-27) +------------------- + +* Avoid accidental auto-submit of new msg form, for subject field. + +* Add ``has_perm()`` etc. to request during the NewRequest event. + +* Fix table sorting for FK reference column in new table wizard. + +* Overhaul the "find by perm" feature a bit. + + +0.9.15 (2023-03-15) +------------------- + +* Remove version workaround for sphinx. + +* Let providers do DB connection setup for web API. + + +0.9.14 (2023-03-09) +------------------- + +* Fix JSON rendering for Cornice API views. + + +0.9.13 (2023-03-08) +------------------- + +* Remove version cap for cornice, now that we require python3. + + +0.9.12 (2023-03-02) +------------------- + +* Add "equal to any of" verb for string-type grid filters. + +* Allow download results for Trainwreck. + + +0.9.11 (2023-02-24) +------------------- + +* Allow sort/filter by vendor for sample files grid. + + +0.9.10 (2023-02-22) +------------------- + +* Add views for sample vendor files. + + +0.9.9 (2023-02-21) +------------------ + +* Validate vendor for catalog batch upload. + + +0.9.8 (2023-02-20) +------------------ + +* Make ``config`` param more explicit, for GridFilter constructor. + + +0.9.7 (2023-02-14) +------------------ + +* Add dedicated view config methods for "view" and "edit help". + + +0.9.6 (2023-02-12) +------------------ + +* Refactor ``Query.get()`` => ``Session.get()`` per SQLAlchemy 1.4. + + +0.9.5 (2023-02-11) +------------------ + +* Use sa-filters instead of sqlalchemy-filters for API queries. + + +0.9.4 (2023-02-11) +------------------ + +* Remove legacy grid for alt codes in product view. + + +0.9.3 (2023-02-10) +------------------ + +* Add dependency for pyramid_retry. + +* Use latest zope.sqlalchemy package. + +* Fix auto-advance on ENTER for login form. + +* Use label handler to avoid deprecated logic. + +* Remove legacy vendor sources grid for product view. + +* Expose setting for POD image URL. + +* Fix multi-file upload widget bug. + + +0.9.2 (2023-02-03) +------------------ + +* Fix auto-focus username for login form. + + +0.9.1 (2023-02-03) +------------------ + +* Stop including deform JS static files. + + +0.9.0 (2023-02-03) +------------------ + +* Officially drop support for python2. + +* Remove all deprecated jquery and ``use_buefy`` logic. + +* Add new Buefy-specific upgrade template. + +* Replace 'default' theme to match 'falafel'. + +* Allow editing the Department field for a Subdepartment. + +* Refactor the Ordering Worksheet generator, per Buefy. + + +0.8.292 (2023-02-02) +-------------------- + +* Always assume ``use_buefy=True`` within main page template. + + +0.8.291 (2023-02-02) +-------------------- + +* Fix checkbox behavior for Inventory Worksheet. + +* Form constructor assumes ``use_buefy=True`` by default. + + +0.8.290 (2023-02-02) +-------------------- + +* Remove support for Buefy 0.8. + +* Add progress bar page for Buefy theme. + + +0.8.289 (2023-01-30) +-------------------- + +* Fix icon for multi-file upload widget. + +* Tweak customer panel header style for new custorder. + +* Add basic API support for printing product labels. + +* Tweak the Ordering Worksheet generator, per Buefy. + +* Refactor the Inventory Worksheet generator, per Buefy. + + +0.8.288 (2023-01-28) +-------------------- + +* Tweak import handler form, some fields not required. + +* Tweak styles for Quantity panel when viewing Receiving row. + + +0.8.287 (2023-01-26) +-------------------- + +* Fix click event for right-aligned buttons on profile view. + + +0.8.286 (2023-01-18) +-------------------- + +* Add some more menu items to default set. + +* Add default view config for Trainwreck. + +* Rename frontend request handler logic to ``SimpleRequestMixin``. + + +0.8.285 (2023-01-18) +-------------------- + +* Misc. tweaks for App Details / Configure Menus. + +* Add specific data type options for new table entry form. + +* Add more views, menus to default set. + +* Add way to override particular 'essential' views. + + +0.8.284 (2023-01-15) +-------------------- + +* Let the API "rawbytes" response be just that, w/ no file. + +* Fix bug when adding new profile via datasync configure. + +* Add default logic to get merge data for object. + +* Add new handlers, TailboneHandler and MenuHandler. + +* Add full set of default menus. + +* Wrap up steps for new table wizard. + +* Add basic "new model view" wizard. + + +0.8.283 (2023-01-14) +-------------------- + +* Tweak how backfill task is launched. + + +0.8.282 (2023-01-13) +-------------------- + +* Show basic column info as row grid when viewing Table. + +* Semi-finish logic for writing new table model class to file. + +* Fix "toggle batch complete" for Chrome browser. + +* Revert logic that assumes all themes use buefy. + +* Refactor tempmon dashboard view, for buefy themes. + +* Prevent listing for top-level Messages view. + + +0.8.281 (2023-01-12) +-------------------- + +* Add new views for App Info, and Configure App. + + +0.8.280 (2023-01-11) +-------------------- + +* Allow all external dependency URLs to be set in config. + + +0.8.279 (2023-01-11) +-------------------- + +* Add basic support for receiving from multiple invoice files. + +* Add support for per-item default discount, for new custorder. + +* Fix panel header icon behavior for new custorder. + +* Refactor inventory batch "add row" page, per new theme. + + +0.8.278 (2023-01-08) +-------------------- + +* Improve "download rows as XLSX" for importer batch. + + +0.8.277 (2023-01-07) +-------------------- + +* Expose, start to honor "units only" setting for products. + + +0.8.276 (2023-01-05) +-------------------- + +* Keep aspect ratio for product images in new custorder. + +* Fix template bug for generating report. + +* Show help link when generating or viewing report, if applicable. + +* Use product handler to normalize data for products API. + + +0.8.275 (2023-01-04) +-------------------- + +* Allow xref buttons to have "internal" links. + + +0.8.274 (2023-01-02) +-------------------- + +* Show only "core" app settings by default. + +* Allow buefy version to be 'latest'. + +* Add beginnings of "New Table" feature. + +* Make invalid email more obvious, in profile view. + +* Expose some settings for Trainwreck DB rotation. + + +0.8.273 (2022-12-28) +-------------------- + +* Add support for Buefy 0.9.x. + +* Warn user when luigi is not installed, for relevant view. + +* Fix HUD display when toggling employee status in profile view. + +* Fix checkbox values when re-running a report. + +* Make static files optional, for new tailbone-integration project. + +* Preserve current tab for page reload in profile view. + +* Add cleanup logic for old Beaker session data. + +* Add basic support for editing help info for page, fields. + +* Override document title when upgrading. + +* Filter by person instead of user, for Generated Reports "Created by". + +* Add "direct link" support for master grids. + +* Add support for websockets over HTTP. + +* Fix product image view for python3. + +* Add "global searchbox" for quicker access to main views. + +* Use minified version of vue.js by default, in falafel theme. + + +0.8.272 (2022-12-21) +-------------------- + +* Add support for "is row checkable" in grids. + +* Add ``make_status_renderer()`` to MasterView. + +* Expose the ``terms`` field for Vendor CRUD. + + +0.8.271 (2022-12-15) +-------------------- + +* Add ``configure_execute_form()`` hook for batch views. + + +0.8.270 (2022-12-10) +-------------------- + +* Fix error if no view supplements defined. + + +0.8.269 (2022-12-10) +-------------------- + +* Show simple error string, when subprocess batch actions fail. + +* Fix ordering worksheet API for date objects. + +* Add the ViewSupplement concept. + +* Cleanup employees view per new supplements. + +* Add common logic for xref buttons, links when viewing object. + +* Add common logic to determine panel fields for product view. + +* Add xref buttons for Customer, Member tabs in profile view. + +* Suppress error if menu entry has bad route name. + + +0.8.268 (2022-12-07) +-------------------- + +* Add support for Beaker >= 1.12.0. + + +0.8.267 (2022-12-06) +-------------------- + +* Fix bug when viewing certain receiving batches. + + +0.8.266 (2022-12-06) +-------------------- + +* Add simple template hook for "before object helpers". + +* Include email address for current API user info. + +* Add support for editing catalog cost in receiving batch, per new theme. + +* Add receiving workflow as param when making receiving batch. + +* Show invoice cost in receiving batch, if "from scratch". + +* Add support for editing invoice cost in receiving batch, per new theme. + +* Add helptext for "Admin-ish" field when editing Role. + + +0.8.265 (2022-12-01) +-------------------- + +* Add way to quickly re-run "any" report. + +* Avoid web config when launching overnight task. + + +0.8.264 (2022-11-28) +-------------------- + +* Add prompt dialog when launching overnight task. + +* Fix page title for datasync status. + +* Use newer config strategy for all views. + +* Auto-format phone number when saving for contact records. + + +0.8.263 (2022-11-21) +-------------------- + +* Update 'testing' watermark for dev background. + +* Let the Luigi handler take care of removing some DB settings. + + +0.8.262 (2022-11-20) +-------------------- + +* Add luigi module/class awareness for overnight tasks. + + +0.8.261 (2022-11-20) +-------------------- + +* Allow disabling, or per-day scheduling, of problem reports. + +* Fix how keys are stored for luigi overnight/backfill tasks. + + +0.8.260 (2022-11-18) +-------------------- + +* Turn on download results feature for Employees. + + +0.8.259 (2022-11-17) +-------------------- + +* Add "between" verb for numeric grid filters. + + +0.8.258 (2022-11-15) +-------------------- + +* Let the auth handler manage user merge. + + +0.8.257 (2022-11-03) +-------------------- + +* Add template method for rendering row grid component. + +* Use people handler to update address. + +* Fix start_date param for pricing batch upload. + +* Use shared logic for rendering percentage values. + +* Log a warning to troubleshoot luigi restart failure. + +* Show UPC for receiving line item if no product reference. + + +0.8.256 (2022-09-09) +-------------------- + +* Add basic per-item discount support for custorders. + +* Make past item lookup optional for custorders. + +* Do not convert date if already a date (for grid filters). + +* Avoid use of ``self.handler`` within batch API views. + + +0.8.255 (2022-09-06) +-------------------- + +* Include ``WorkOrder.estimated_total`` for API. + +* Add default normalize logic for API views. + +* Disable "Delete Results" button if no results, for row grid. + +* Move logic for "bulk-delete row objects" into MasterView. + +* Convert value for more date filters; only add condition if valid. + + +0.8.254 (2022-08-30) +-------------------- + +* Improve parsing of purchase order quantities. + +* Expose more attrs for new product batch rows. + + +0.8.253 (2022-08-30) +-------------------- + +* Convert value for date filter; only add condition if valid. + +* Add 'warning' flash messages to old jquery base template. + +* Add uom fields, configurable template for newproduct batch. + + +0.8.252 (2022-08-25) +-------------------- + +* Avoid error when no datasync profiles configured. + +* Add max lengths when editing person name via profile view. + + +0.8.251 (2022-08-24) +-------------------- + +* Fix index title for datasync configure page. + +* Add basic support for backfill Luigi tasks. + + +0.8.250 (2022-08-21) +-------------------- + +* Add ``render_person_profile()`` method to MasterView. + +* Add way to declare failure for an upgrade. + +* Add websockets progress, "multi-system" support for upgrades. + +* Add global context from handler, for email previews. + +* Allow configuring datasync watcher kwargs. + +* Expose, honor "admin-ish" flag for roles. + + +0.8.249 (2022-08-18) +-------------------- + +* Add brief delay before declaring websocket broken. + +* Add basic views for Luigi / overnight tasks. + +* Expose setting for auto-correct when receiving from invoice. + + +0.8.248 (2022-08-17) +-------------------- + +* Redirect to custom index URL when user cancels new custorder entry. + +* Add ``get_next_url_after_submit_new_order()`` for customer orders. + +* Add first experiment with websockets, for datasync status page. + +* Allow user feedback to request email reply back. + + +0.8.247 (2022-08-14) +-------------------- + +* Avoid double-quotes in field error messages JS code. + +* Add the FormPosterMixin to ProfileInfo component. + +* Fix default help URLs for ordering, receiving. + +* Move handheld batch view module to appropriate location. + +* Refactor usage of ``get_vendor()`` lookup. + +* Consolidate master API view logic. + + +0.8.246 (2022-08-12) +-------------------- + +* Couple of API tweaks for work orders. + +* Standardize merge logic when a handler is defined for it. + + +0.8.245 (2022-08-10) +-------------------- + +* Add convenience wrapper to make customer field widget, etc.. + +* Some API tweaks to support a byjove app. + +* Tweak flash msg, logging when batch population fails. + +* Log traceback output when batch action subprocess fails. + +* Add initial views for work orders. + +* Fix sequence of events re: grid component creation. + +* Allow download results for Customers grid. + + +0.8.244 (2022-08-08) +-------------------- + +* Add separate product grid filters for Category Code, Category Name. + + +0.8.243 (2022-08-08) +-------------------- + +* Add button to raise bogus error, for testing email alerts. + +* Make sure "configure" pages use AppHandler to save/delete settings. + +* Expose setting for sendmail failure alerts. + + +0.8.242 (2022-08-07) +-------------------- + +* Always show "all" email settings if user has config perm. + + +0.8.241 (2022-08-06) +-------------------- + +* Add support for toggling visibility of email profile settings. + + +0.8.240 (2022-08-05) +-------------------- + +* Clean up URL routes for row CRUD. + + +0.8.239 (2022-08-04) +-------------------- + +* Invalidate config cache when raw setting is deleted. + + +0.8.238 (2022-08-03) +-------------------- + +* Improve "touch" logic for employees. + +* Stop using the old ``rattail.db.api.settings`` module. + +* Force cache invalidation when Raw Setting is edited. + + +0.8.237 (2022-07-27) +-------------------- + +* Add some more views to potentially include via poser. + +* Misc. improvements for desktop receiving views. + + +0.8.236 (2022-07-25) +-------------------- + +* Add setting to expose/hide "active in POS" customer flag. + +* Allow optional row grid title for master view. + +* Add basic/minimal merge support for customers. + +* Assume default vendor for new receiving batch. + +* Add basic edit support for Purchases. + +* Add ``iter(Form)`` logic, to loop through fields. + +* Add "auto-receive all items" support for receiving batch API. + + +0.8.235 (2022-07-22) +-------------------- + +* Split out rendering of ``this-page`` component in falafel theme. + +* Allow download of results for common product-related tables. + +* Make caching products optional, when creating vendor catalog batch. + +* Expose the ``complete`` flag for pricing batch. + +* Add ``template_kwargs_clone()`` stub for master view. + +* Misc deform template improvements. + + +0.8.234 (2022-07-18) +-------------------- + +* Fix form validation for app settings page w/ buefy theme. + +* Honor default pagesize for all grids, per setting. + +* Add basic "download results" for Subdepartments grid. + +* Add new-style config defaults for BrandView. + + +0.8.233 (2022-06-24) +-------------------- + +* Add minimal buefy support for 'percentinput' field widget. + +* Add autocomplete support for subdepartments. + + +0.8.232 (2022-06-14) +-------------------- + +* Let default grid page size correspond to first option. + +* Add start date support for "future" pricing batch. + + +0.8.231 (2022-05-15) +-------------------- + +* Expose config for identifying supported vendors. + +* Allow restricting to supported vendors only, for Receiving. + + +0.8.230 (2022-05-10) +-------------------- + +* Sort roles list when viewing a user. + +* Add grid workarounds when data is list instead of query. + + +0.8.229 (2022-05-03) +-------------------- + +* Tweak how family data is displayed. + + +0.8.228 (2022-04-13) +-------------------- + +* Fix quotes for field helptext. + +* Flush early when populating batch, to ensure error is shown. + + +0.8.227 (2022-04-04) +-------------------- + +* Add touch for report codes. + +* Raise 404 if report not found. + +* Add template kwargs stub for ``view_row()``. + +* Log error when failing to submit new custorder batch. + +* Honor case vs. unit restrictions for new custorder. + +* Tweak where description field is shown for receiving batch. + +* Fix "touch" url for non-standard record types. + + +0.8.226 (2022-03-29) +-------------------- + +* Let errors raise when showing poser reports. + + +0.8.225 (2022-03-29) +-------------------- + +* Force session flush within try/catch, for batch refresh. + + +0.8.224 (2022-03-25) +-------------------- + +* Improve vendor validation for new receiving batch. + +* Use common logic for fetching batch handler. + + +0.8.223 (2022-03-21) +-------------------- + +* Show link to txn as field when viewing trainwreck item. + + +0.8.222 (2022-03-17) +-------------------- + +* Expose custorder xref markers for trainwreck. + + +0.8.221 (2022-03-16) +-------------------- + +* Always show batch params by default when viewing. + +* Show helptext when applicable for "new batch from product query". + +* Make problem report titles searchable in grid. + + +0.8.220 (2022-03-15) +-------------------- + +* Log error instead of warning, when batch population fails. + +* Add default help link for Receiving feature. + + +0.8.219 (2022-03-10) +-------------------- + +* Cleanup grid filters for vendor catalog batches. + +* Cleanup view config syntax for vendor catalog batch. + +* Add workaround when inserting new fields to form field list. + +* Add ``Form.insert()`` method, to insert field based on index. + +* Default behavior for report chooser should *not* be form/dropdown. + + +0.8.218 (2022-03-08) +-------------------- + +* Log warning/traceback when failing to include a configured view. + +* Fix gotcha when defining new provider views. + +* Bump the default Buefy version to 0.8.13. + + +0.8.217 (2022-03-07) +-------------------- + +* Add the "provider" concept, let them configure db sessions. + +* Let providers add extra views, options for includes config. + +* Let tailbone providers include static views. + +* Link to email settings profile when viewing email attempt. + + +0.8.216 (2022-03-05) +-------------------- + +* Show list of generated reports when viewing Poser Report. + +* Show link back to Poser Report when viewing Generated Report. + +* Always include ``app_title`` in global template rendering context. + +* Update some more view config syntax. + +* Make common web view a bit more common. + +* Improve the Poser Setup page; allow poser dir refresh. + +* Add initial/basic support for configuring "included views". + +* Add ``tailbone.views.essentials`` to include common / "core" views. + +* Add flash message when upgrade execution completes (pass or fail). + + +0.8.215 (2022-03-02) +-------------------- + +* Show toast msg instead of alert after sending feedback. + +* Add basic support for Poser reports, list/create. + + +0.8.214 (2022-03-01) +-------------------- + +* Params should be readonly when editing batch. + +* Tweak styles for links in object helper panel. + + +0.8.213 (2022-03-01) +-------------------- + +* Add simple searchable column support for non-AJAX grids. + +* Fix stdout/stderr fields for upgrade view. + +* Pass query along for download results, so subclass can modify. + +* Avoid making discounts data if missing field, for trainwreck item view. + + +0.8.212 (2022-02-26) +-------------------- + +* Add page/way to configure main menus. + + +0.8.211 (2022-02-25) +-------------------- + +* Add view template stub for trainwreck transaction. + +* Add auto-filter hyperlinks for batch row status breakdown. + +* Auto-filter hyperlinks for PO vs. invoice breakdown in Receiving. + +* Add grid hyperlinks for trainwreck transaction line items. + +* Use dict instead of custom object to represent menus. + +* Expose "discount type" for Trainwreck line items. + + +0.8.210 (2022-02-20) +-------------------- + +* Only show DB picker for permissioned users. + +* Expose some new trainwreck fields; per-item discounts. + +* Show SRP as currency for vendor catalog batch. + + +0.8.209 (2022-02-16) +-------------------- + +* Fix progress bar when running problem report. + + +0.8.208 (2022-02-15) +-------------------- + +* Allow override of navbar-end element in falafel theme header. + +* Add initial support for editing user preferences. + +* Add FormPosterMixin to WholePage class. + + +0.8.207 (2022-02-13) +-------------------- + +* Try out new config defaults function for some views (user, customer). + +* Add highlight for non-active users, customers in grid. + +* Prevent cache for index pages by default, unless configured not to. + +* Cleanup labels for Vendor/Code "preferred" vs. "any" in products grid. + +* Add config for showing ordered vs. shipped amounts when receiving. + +* Tweak how "duration" fields are rendered for grids, forms. + +* New upgrades should be enabled by default. + + +0.8.206 (2022-02-08) +-------------------- + +* Add "full lookup" product search modal for new custorder page. + + +0.8.205 (2022-02-05) +-------------------- + +* Tweak how product key field is handled for product views. + +* Add some autocomplete workarounds for new vendor catalog batch. + + +0.8.204 (2022-02-04) +-------------------- + +* Add ``CustomerGroupAssignment`` to customer version history. + + +0.8.203 (2022-02-01) +-------------------- + +* Expose batch params for vendor catalogs. + + +0.8.202 (2022-01-31) +-------------------- + +* Make "generate report" the same as "create new generated report". + + +0.8.201 (2022-01-31) +-------------------- + +* Show helptext for params when generating new report. + +* Tweak handling of empty params when generating report. + + +0.8.200 (2022-01-31) +-------------------- + +* Improve profile link helper for buefy themes. + +* Add project generator support for rattail-integration, tailbone-integration. + + +0.8.199 (2022-01-26) +-------------------- + +* Tweak the "auto-receive all" tool for Chrome browser. + + +0.8.198 (2022-01-25) +-------------------- + +* Only expose "product" departments within product view dropdowns. + + +0.8.197 (2022-01-19) +-------------------- + +* Use buefy input for quickie search. + + +0.8.196 (2022-01-15) +-------------------- + +* Use the new label handler. + + +0.8.195 (2022-01-13) +-------------------- + +* Strip whitespace for new customer fields, in new custorder page. + + +0.8.194 (2022-01-12) +-------------------- + +* Include all static files in manifest. + +* Update usage of ``app.get_email_handler()`` to avoid warnings. + + +0.8.193 (2022-01-10) +-------------------- + +* Add buefy support for quick-printing product labels; also speed bump. + +* Add way to set form-wide schema validator. + +* Add progress support when deleting a batch. + +* Expose the Sale, TPR, Current price fields for label batch. + + +0.8.192 (2022-01-08) +-------------------- + +* Add configurable template file for vendor catalog batch. + +* Some aesthetic improvements for vendor catalog batch. + +* Several disparate changes needed for vendor catalog improvements. + +* Expose, honor "allow future" setting for vendor catalog batch. + +* Add config for supported vendor catalog parsers. + +* Update some method calls to avoid deprecation warnings. + + +0.8.191 (2022-01-03) +-------------------- + +* Fix permission check for input file template links. + +* Remove usage of ``app.get_designated_import_handler()``. + +* Add basic configure page for Trainwreck. + +* Use ``AuthHandler.get_permissions()``. + + +0.8.190 (2021-12-29) +-------------------- + +* Show create button on "most" pages for a master view. + +* Expose products setting for type 2 UPC lookup. + +* Add basic "resolve" support for person, product from new custorder. + + +0.8.189 (2021-12-23) +-------------------- + +* Add basic "pending product" support for new custorder batch. + +* Improve email bounce view per buefy theme. + + +0.8.188 (2021-12-20) +-------------------- + +* Flag discontinued items for main Products grid. + + +0.8.187 (2021-12-20) +-------------------- + +* Add common configuration logic for "input file templates". + +* Add some standard CRUD buttons for buefy themes. + + +0.8.186 (2021-12-17) +-------------------- + +* Render "pretty" UPC by default, for batch row form fields. + +* Let config decide which versions of vue.js and buefy to use. + + +0.8.185 (2021-12-15) +-------------------- + +* Allow for null price when showing price history. + +* Overhaul desktop views for receiving, for efficiency. + +* Add some basic "config" views, to obviate some App Settings. + +* Add "jump to" chooser in App Settings, for various "configure" pages. + +* Fix params field when deleting a report. + +* Add some smarts when making batch execution form schema. + + +0.8.184 (2021-12-09) +-------------------- + +* Refactor "receive row" and "declare credit" tools per buefy theme. + +* Allow "auto-receive all items" batch feature in production. + +* Make "view row" prettier for receiving batch, for buefy themes. + +* Add buttons to edit, confirm cost for receiving batch row view. + + +0.8.183 (2021-12-08) +-------------------- + +* Add basic views to expose Problem Reports, and run them. + +* Only include ``--runas`` arg if we have a value, for import jobs. + +* Assume default receiving workflow if there is only one. + +* Fix bug when report has no params dict. + + +0.8.182 (2021-12-07) +-------------------- + +* Fix form ref bug, for batch execution. + + +0.8.181 (2021-12-07) +-------------------- + +* Bugfix. + + +0.8.180 (2021-12-07) +-------------------- + +* Add basic import/export handler views, tool to run jobs. + +* Overhaul import handler config etc.: + * add ``MasterView.configurable`` concept, ``/configure.mako`` template + * add new master view for DataSync Threads (needs content) + * tweak view config for DataSync Changes accordingly + * update the Configure DataSync page per ``configurable`` concept + * add new Configure Import/Export page, per ``configurable`` + * add basic views for Raw Permissions + +* Honor "safe for web app" flags for import/export handlers. + +* When viewing report output, show params as proper buefy table. + + +0.8.179 (2021-12-03) +-------------------- + +* Expose the Sale Price and TPR Price for product views. + + +0.8.178 (2021-11-29) +-------------------- + +* Add page for configuring datasync. + + +0.8.177 (2021-11-28) +-------------------- + +* Show current/sale pricing for products in new custorder page. + +* Add simple search filters for past items dialog in new custorder. + + +0.8.176 (2021-11-25) +-------------------- + +* Add basic support for receiving from PO with invoice. + +* Don't use multi-select for new report in buefy themes. + + +0.8.175 (2021-11-17) +-------------------- + +* Fix bug when product has empty suggested price. + +* Show ordered quantity when viewing costing batch row. + + +0.8.174 (2021-11-14) +-------------------- + +* Expose the "sync users" flag for Roles. + + +0.8.173 (2021-11-11) +-------------------- + +* Improve error handling when executing a custorder batch. + +* Fix "download results" support for Products. + + +0.8.172 (2021-11-11) +-------------------- + +* Add permission for viewing "all" employees. + + +0.8.171 (2021-11-11) +-------------------- + +* Add "true margin" to products XLSX export. + +* Add initial ``VersionMasterView`` base class. + +* Add views for ``PendingProduct`` model; also ``DepartmentWidget``. + + +0.8.170 (2021-11-09) +-------------------- + +* Fix dynamic content title for "view profile" page. + + +0.8.169 (2021-11-08) +-------------------- + +* Use products handler to get image URL. + +* Show some more product attributes in custorder item selection popup. + +* Auto-select Quantity tab when editing item for new custorder. + +* Let user "add past product" when making new custorder. + +* Let handler restrict available invoice parser options. + +* Cleanup grid columns for receiving batches. + +* Fall back to empty string for product regular price. + + +0.8.168 (2021-11-05) +-------------------- + +* Make separate method for writing results XLSX file. + +* Add ``render_brand()`` method for MasterView. + +* Add link to download generic template for vendor catalog batch. + + +0.8.167 (2021-11-04) +-------------------- + +* Try to prevent caching for any /index (grid) page. + +* Fix product view page when user cannot view version history. + +* Move some custorder logic to handler; allow force-swap of product selection. + +* Honor the "product price may be questionable" flag for new custorder. + +* Show unit price in line items grid for new custorder. + +* Avoid exposing batch params when creating a batch. + + +0.8.166 (2021-11-03) +-------------------- + +* Fix the Department filter for Products grid, for jquery themes. + + +0.8.165 (2021-11-02) +-------------------- + +* Optionally set the ``sticky-header`` attribute for main buefy grids. + +* Show case qty by default for costing batch rows. + +* Highlight the "did not receive" rows for purchase batch. + +* Improve validation for Person field of User form. + +* Omit "edit" link unless user has perm, for Customer "people" subgrid. + +* Highlight "cannot calculate price" rows for new product batch. + + +0.8.164 (2021-10-20) +-------------------- + +* Give custorder batch handler a couple ways to affect adding new items. + +* Refactor to leverage all existing methods of auth handler. + +* Overhaul the autocomplete component, for sake of new custorder. + +* Improve "refresh contact", show new fields in green for custorder. + +* Invoke handler when adding new item to custorder batch. + +* Add basic "price needs confirmation" support for custorder. + +* Clean up the product selection UI for new custorder. + + +0.8.163 (2021-10-14) +-------------------- + +* Misc. tweaks for users, roles. + + +0.8.162 (2021-10-14) +-------------------- + +* Cleanup form display a bit, for App Settings. + +* Invoke the auth handler to cache user permissions etc. + + +0.8.161 (2021-10-13) +-------------------- + +* Add ``debounce()`` wrapper for buefy autocomplete. + +* Leverage the auth handler for main user login. + + +0.8.160 (2021-10-11) +-------------------- + +* Stop rounding case/unit cost fields to 2 places for purchase batch. + +* Fix some phone/email bugs for new custorder page. + +* Fix bug when making context for mailing address. + +* Improve display, handling for "add contact info to customer record". + + +0.8.159 (2021-10-10) +-------------------- + +* Simplify template context customization for view_profile_buefy. + + +0.8.158 (2021-10-07) +-------------------- + +* Add support for "new customer" when creating new custorder. + +* Improve contact name handling for new custorder. + + +0.8.157 (2021-10-06) +-------------------- + +* Some tweaks for invoice costing batch views. + +* Add "restrict contact info" features for new custorder batch. + +* Add "contact update request" workflow for new custorder batch. + + +0.8.156 (2021-10-05) +-------------------- + +* Show "contact notes" when creating new custorder. + +* Improve phone editing for new custorder. + +* Add button to refresh contact info for new custorder. + +* Overhaul the "Personal" tab of profile view. + +* Refactor the Employee tab of profile view, per better patterns. + + +0.8.155 (2021-10-01) +-------------------- + +* Refactor autocomplete view logic to leverage new "autocompleters". + + +0.8.154 (2021-09-30) +-------------------- + +* Initial (basic) views for invoice costing batches. + + +0.8.153 (2021-09-28) +-------------------- + +* Improve phone/email handling when making new custorder. + +* Avoid "detach person" logic if not supported by view class. + + +0.8.152 (2021-09-27) +-------------------- + +* Allow changing status, adding notes for customer order items. + + +0.8.151 (2021-09-27) +-------------------- + +* Overhaul new custorder so contact may be either Person or Customer. + +* Add a dropdown of choices to the Department filter for Products grid. + + +0.8.150 (2021-09-26) +-------------------- + +* Refactor several "field grids" per Buefy theme. + +* Display the Store field for Customer Orders. + + +0.8.149 (2021-09-25) +-------------------- + +* Improve default autocomplete query logic, w/ multiple ILIKE. + +* Add placeholder to customer lookup for new order. + +* Invoke handler for customer autocomplete when making new custorder. + +* Improve "employees" list when viewing a department, for buefy themes. + +* Add products row grid for misc. org table views. + + +0.8.148 (2021-09-22) +-------------------- + +* Add way to update Employee ID from profile view. + + +0.8.147 (2021-09-22) +-------------------- + +* Add way to override grid action label rendering. + + +0.8.146 (2021-09-21) +-------------------- + +* Misc. improvements for customer order views. + + +0.8.145 (2021-09-19) +-------------------- + +* Allow setting the "exclusive" sequence of grid filters. + + +0.8.144 (2021-09-16) +-------------------- + +* Invoke handler when request is made to merge 2 people. + + +0.8.143 (2021-09-12) +-------------------- + +* Add way to customize product autocomplete for new custorder. + + +0.8.142 (2021-09-09) +-------------------- + +* Set quantity type when viewing vendor lead times, order intervals. + + +0.8.141 (2021-09-09) +-------------------- + +* Add /people API endpoint; allow for "native sort". + +* Allow override of "create" permission in API. + +* Add the ``Grid.remove()`` method, deprecate ``hide_column()`` etc. + +* Improve error handling for purchase batch. + + +0.8.140 (2021-09-01) +-------------------- + +* Make it easier to override rendering grid component in master/index. + +* Always show all grid actions...for now. + +* Allow grid columns to be *invisible* (but still present in grid). + +* Improve UI, customization hooks for new custorder batch. + +* Add hover text for vendor ID column of pricing batch row grid. + +* Fix size of roles multi-select when editing user. + +* Allow "touch" action for employees. + + +0.8.139 (2021-08-26) +-------------------- + +* Tweak how email preview is sent, and attempt "to" is displayed. + +* Move "merge 2 people" logic into People Handler. + +* Expose "merge request tracking" feature for People data. + +* Allow customization of row 'view' action url. + +* Require explicit opt-in for "clicking grid row checks box" feature. + +* Add ``before_render_index()`` customization hook for MasterView. + + +0.8.138 (2021-08-04) +-------------------- + +* Let feedback forms define their own email key. + + +0.8.137 (2021-07-15) +-------------------- + +* Set UPC renderer for delproduct batch row. + +* Expose ``pack_size`` for delproduct batch. + + +0.8.136 (2021-06-18) +-------------------- + +* Include "is/not null" filters for GPC fields. + + +0.8.135 (2021-06-15) +-------------------- + +* Add 'v' prefix for release package diff links. + + +0.8.134 (2021-06-15) +-------------------- + +* Allow config to set favicon and header image. + + +0.8.133 (2021-06-11) +-------------------- + +* Allow customization of rendering version diff values. + +* Allow direct creation of new label batches. + +* Allow generating project which integrates w/ LOC SMS. + + +0.8.132 (2021-05-03) +-------------------- + +* Highlight "has inventory" rows for delete item batch. + +* Add csrftoken to TailboneForm js. + +* Freeze pyramid version at 1.x. + + +0.8.131 (2021-04-12) +-------------------- + +* Show current price date range as hover text, for products grid. + +* Make it easier to extend "common" API views. + +* Accept any decimal numbers for API inventory batch counts. + + +0.8.130 (2021-03-30) +-------------------- + +* Catch and show error, if one happens when making batch from product query. + +* Expose the new ``Store.archived`` flag. + + +0.8.129 (2021-03-11) +-------------------- + +* Add support for ``inactivity_months`` field for delete product batch. + +* Expose new fields for Trainwreck. + +* Fix enum display for customer order status. + + +0.8.128 (2021-03-05) +-------------------- + +* Allow per-user stylesheet for Buefy themes. + +* Expose ``date_created`` for delete product batches. + + +0.8.127 (2021-03-02) +-------------------- + +* Use end time as default filter, sort for Trainwreck. + +* Avoid encoding values as string, for integer grid filters. + +* Fix message recipients for Reply / Reply-All, with Buefy themes. + +* Handle row click as if checkbox was clicked, for checkable grid. + +* Highlight delete product batch rows with "pending customer orders" status. + +* Add hover text for subdepartment name, in pricing batch row grid. + + +0.8.126 (2021-02-18) +-------------------- + +* Allow customization of main Buefy CSS styles, for falafel theme. + +* Add special "contains any of" verb for string-based grid filters. + +* Add special "equal to any of" verb for UPC-related grid filters. + +* Tweaks per "delete products" batch. + +* Misc. tweaks for vendor catalog batch. + +* Add support for "default" trainwreck model. + + +0.8.125 (2021-02-10) +-------------------- + +* Fix some permission bugs when showing batch tools etc. + +* Render batch execution description as markdown. + +* Cleanup default display for vendor catalog batches. + +* Make errors more obvious, when running batch commands as subprocess. + +* Add styles for field labels in profile view. + + +0.8.124 (2021-02-04) +-------------------- + +* Fix bug when editing a Person. + + +0.8.123 (2021-02-04) +-------------------- + +* Fix config defaults for PurchaseView. + +* Add stub methods for ``MasterView.template_kwargs_view()`` etc. + +* Update references to vendor catalog batches etc. + +* Fix display of handheld batch links, when viewing label batch. + +* Prevent updates to batch rows, if batch is immutable. + + +0.8.122 (2021-02-01) +-------------------- + +* Normalize naming of all traditional master views. + +* Undo recent ``base.css`` changes for ``

`` tags. + +* Misc. improvements for ordering batches, purchases. + +* Purge things for legacy (jquery) mobile, and unused template themes. + +* Make handler responsible for possible receiving modes. + +* Split "new receiving batch" process into 2 steps: choose, create. + +* Add initial "scanning" feature for Ordering Batches. + +* Add support for "nested" menu items. + +* Add icon for Help button. + + +0.8.121 (2021-01-28) +-------------------- + +* Tweak how vendor link is rendered for readonly field. + +* Use "People Handler" to update names, when editing person or user. + + +0.8.120 (2021-01-27) +-------------------- + +* Initial support for adding items to, executing customer order batch. + +* Add changelog link for Theo, in upgrade package diff. + +* Hide "collect from wild" button for UOMs unless user has permission. + + +0.8.119 (2021-01-25) +-------------------- + +* Don't create new person for new user, if one was selected. + +* Allow newer zope.sqlalchemy package. + +* Add variant transaction logic per zope.sqlalchemy 1.1 changes. + +* Add CSS styles for 'codehilite' a la Pygments. + +* Add feature to generate new features... + +* Add views for "delete product" batch. + +* Set ``self.model`` when constructing new View. + +* Add some generic render methods to MasterView. + +* Add custom ``base.css`` for falafel theme. + +* Add master view for Units of Measure mapping table. + +* Add woocommerce package links for sake of upgrade diff view. + +* Add basic web API app, for simple use cases. + + +0.8.118 (2021-01-10) +-------------------- + +* Show node title in header for Login, About pages. + +* Allow changing protected user password when acting as root. + +* Allow specifying the size of a file, for ``readable_size()`` method. + +* Try to show existing filename, for upload widget. + +* Add basic support for "download" and "rawbytes" API views. + + +0.8.117 (2020-12-16) +-------------------- + +* Add common "form poster" logic, to make CSRF token/header names configurable. + +* Refactor the feedback form to use common form poster logic. + + +0.8.116 (2020-12-15) +-------------------- + +* Add basic views for IFPS PLU Codes. + +* Add very basic support for merging 2 People. + +* Tweak spacing for header logo + title, in falafel theme. + + +0.8.115 (2020-12-04) +-------------------- + +* Add the "Employee Status" filter to People grid. + +* Add "is empty" and related verbs, for "string" type grid filters. + +* Assume composite PK when fetching instance for master view. + + +0.8.114 (2020-12-01) +-------------------- + +* Misc. tweaks to vendor catalog views. + +* Tweak how an "enum" grid filter is initialized. + +* Add "generic" Employee tab feature, for profile view. + + +0.8.113 (2020-10-13) +-------------------- + +* Tweak how global DB session is created. + + +0.8.112 (2020-09-29) +-------------------- + +* Add support for "list" type of app settings (w/ textarea). + +* Add feature to "download rows for results" in master index view. + +* Fix "refresh results" for batches, in Buefy theme. + + +0.8.111 (2020-09-25) +-------------------- + +* Allow alternate engine to act as 'default' when multiple are available. + +* Fix grid bug when paginator is not involved. + + +0.8.110 (2020-09-24) +-------------------- + +* Add ``user_is_protected()`` method to core View class. + +* Change how we protect certain person, employee records. + +* Add global help URL to login template. + +* Fix bug when fetching partial versions data grid. + + +0.8.109 (2020-09-22) +-------------------- + +* Add 'warning' class for 'delete' action in b-table grid. + +* Add "worksheet file" pattern for editing batches. + +* Avoid unhelpful error when perm check happens for "re-created" DB user. + +* Prompt user if they try to send email preview w/ no address. + +* Don't expose "timezone" for input when generating 'fabric' project. + +* Add some more field hints when generating 'fabric' project. + +* Show node title in header, for home page. + +* Remove unwanted columns for default Products grid. + + +0.8.108 (2020-09-16) +-------------------- + +* Allow custom props for TailboneForm component. + +* Remove some custom field labels for Vendor. + +* Add support for generating new 'fabric' project. + + +0.8.107 (2020-09-14) +-------------------- + +* Stop including 'complete' filter by default for purchasing batches. + +* Overhaul project changelog links for upgrade pkg diff table. + +* Add support/views for generating new custom projects, via handler. + + +0.8.106 (2020-09-02) +-------------------- + +* Add progress for generating "results as CSV/XLSX" file to download. + +* Use utf8 encoding when downloading results as CSV. + +* Add new/flexible "download results" feature. + +* Fix spacing between components in "grid tools" section. + +* Add support for batch execution options in Buefy themes. + +* Improve auto-handling of "local" timestamps. + +* Expose ``Product.average_weight`` field. + + +0.8.105 (2020-08-21) +-------------------- + +* Tweaks for export views, to make more generic. + +* Add config for "global" help URL. + +* Remove ``

`` tag around "no results" for minimal b-table. + +* Allow for unknown/missing "changed by" user for product price history. + +* Add buefy theme support for ordering worksheet. + +* Don't require department by default, for new purchasing batch. + + +0.8.104 (2020-08-17) +-------------------- + +* Make "download row results" a bit more generic. + +* Add pagination to price, cost history grids for product view. + + +0.8.103 (2020-08-13) +-------------------- + +* Tweak config methods for customer master view. + + +0.8.102 (2020-08-10) +-------------------- + +* Improve rendering of ``true_margin`` column for pricing batch row grid. + + +0.8.101 (2020-08-09) +-------------------- + +* Fix missing scrollbar when version diff table is too wide for screen. + +* Add basic web views for "new customer order" batches. + +* Tweak the buefy autocomplete component a bit. + +* Add basic/unfinished "new customer order" page/feature. + +* Add ``protected_usernames()`` config function. + +* Add ``model`` to global template context, plus ``h.maxlen()``. + +* Coalesce on ``User.active`` when merging. + +* Expose user reference(s) for employees. + + +0.8.100 (2020-07-30) +-------------------- + +* Add more customization hooks for making grid actions in master view. + + +0.8.99 (2020-07-29) +------------------- + +* Add ``self.cloning`` convenience indicator for master view. + +* Use handler ``do_delete()`` method when deleting a batch. + + +0.8.98 (2020-07-26) +------------------- + +* Tweak field label for ``Product.item_id``. + +* Make field list explicit for Department views. + +* Make field list explicit for Store views. + +* Don't allow "execute results" for any batches by default. + +* Fix pagination sync issue with buefy grid tables. + +* Fix permissions wiget bug when creating new role. + +* Tweak "coalesce" logic for merging field data. + + +0.8.97 (2020-06-24) +------------------- + +* Add dropdown, autohide magic when editing Role permissions. + +* Add ability to download roles / permissions matrix as Excel file. + +* Improve support for composite key in master view. + +* Use byte string filters for row grid too. + +* Convert mako directories to list, if it's a string. + + +0.8.96 (2020-06-17) +------------------- + +* Don't allow edit/delete of rows, if master view says so. + + +0.8.95 (2020-05-27) +------------------- + +* Cap version for 'cornice' dependency. + +* Let each grid component have a custom name, if needed. + + +0.8.94 (2020-05-20) +------------------- + +* Expose "shelved" field for pricing batches. + +* Sort available reports by name, if handler doesn't specify. + + +0.8.93 (2020-05-15) +------------------- + +* Parse pip requirements file ourselves, instead of using their internals. + +* Don't auto-include "Guest" role when finding roles w/ permission X. + + +0.8.92 (2020-04-07) +------------------- + +* Allow the home page to include quickie search. + + +0.8.91 (2020-04-06) +------------------- + +* Add "danger" style for "delete" grid row action. + +* Misc. API improvements for sake of mobile receiving. + +* Use proper cornice service registration, for API batch execute etc. + +* Add common permission for sending user feedback. + +* Fix the "change password" form per Buefy theme. + +* Expose the ``Role.notes`` field for view/edit. + +* Add "local only" column to Users grid. + +* Fix row status filter for Import/Export batches. + +* Add "generic" ``render_id_str()`` method to MasterView. + +* Stop raising an error if view doesn't define row grid columns. + +* Add helper function, ``get_csrf_token()``. + +* Add support for "choice" widget, for report params. + +* Allow bulk-delete, merge for Brands table. + +* Move inventory batch view to its proper location. + +* Allow bulk-delete for Inventory Batches. + +* Move "most" inventory batch logic out of view, to underlying handler. + +* Add initial API views for inventory batches. + +* Add basic dashboard page for TempMon. + +* Let config totally disable the old/legacy jQuery mobile app. + +* Defer fetching price, cost history when viewing product details. + + +0.8.90 (2020-03-18) +------------------- + +* Add basic "ordering worksheet" API. + +* Tweak GPC grid filter, to better handle spaces in user input. + +* Only show tables for "public" schema. + +* Remove old/unwanted Vue.js index experiment, for Users table. + +* Misc. changes to User, Role permissions and management thereof. + +* Don't let user delete roles to which they belong, without permission. + +* Prevent deletion of department which still has products. + +* Add sort/filter for Department Name, in Subdepartments grid. + +* Allow "touch" for Department, Subdepartment. + +* Expose ``Customer.number`` field. + +* Add support for "bulk-delete" of Person table. + +* Allow customization for Customers tab of Profile view. + +* Expose default email address, phone number when editing a Person. + +* Add/improve various display of Member data. + + +0.8.89 (2020-03-11) +------------------- + +* Refactor "view profile" page per latest Buefy theme conventions. + +* Move logic for Order Form worksheet into purchase batch handler. + +* Make sure all contact info is "touched" when touching person record. + + +0.8.88 (2020-03-05) +------------------- + +* Fix batch row status breakdown for Buefy themes. + +* Add support for refreshing multiple batches (results) at once. + +* Remove "api." prefix for default route names, in API master views. + +* Allow "touch" for vendor records. + + +0.8.87 (2020-03-02) +------------------- + +* Add new "master" API view class; refactor products and batches to use it. + +* Refactor all API views thus far, to use new v2 master. + +* Use Cornice when registering all "service" API views. + + +0.8.86 (2020-03-01) +------------------- + +* Add toggle complete, more normalized row fields for odering batch API. + +* Return employee_uuid along with user info, from API. + +* Add support for executing ordering batches via API. + +* Fix how we fetch employee history, for profile view. + +* Cleanup main version history views for Buefy theme. + +* Fix product price, cost history dialogs, for Buefy theme. + +* Fix some basic product editing features. + + +0.8.85 (2020-02-26) +------------------- + +* Overhaul the /ordering batch API somewhat; update docs. + +* Tweak ``save_edit_row_form()`` of purchase batch view, to leverage handler. + +* Tweak ``worksheet_update()`` of ordering batch view, to leverage handler. + +* Fix "edit row" logic for ordering batch. + +* Raise 404 not found instead of error, when user is not employee. + +* Send batch params as part of normalized API. + + +0.8.84 (2020-02-21) +------------------- + +* Add API view for changing current user password. + +* Return new user permissions when logging in via API. + + +0.8.83 (2020-02-12) +------------------- + +* Use new ``Email.obtain_sample_data()`` method when generating preview. + +* Add some custom display logic for "current price" in pricing batch. + +* Fix email preview for TXT templates on python3. + +* Allow override of "email key" for user feedback, sent via API. + +* Add way to prevent user login via API, per custom logic. + +* Add common ``get_user_info()`` method for all API views. + +* Return package names as list, from "about" page from API. + + +0.8.82 (2020-02-03) +------------------- + +* Fix vendor ID/name for Excel download of pricing batch rows. + +* Add red highlight for SRP breach, for generic product batch. + +* Make sure falafel theme is somewhat available by default. + + +0.8.81 (2020-01-28) +------------------- + +* Include regular price changes, for current price history dialog. + +* Allow populate of new pricing batch from products w/ "SRP breach". + +* Tweak how we import pip internal things, for upgrade view. + +* Sort report options by name, when choosing which to generate. + +* Add warning for "price breaches SRP" rows in pricing batch. + + +0.8.80 (2020-01-20) +------------------- + +* Hide the SRP history link for new buefy themes. + +* Add regular price history dialog for product view. + +* Add support for Row Status Breakdown, for Import/Export batches. + +* Cleanup "diff" table for importer batch row view, per Buefy theme. + +* Highlight SRP in red, if reg price is greater. + +* Expose batch ID, sequence for datasync change queue. + +* Add "current price history" dialog for product view. + +* Add "cost history" dialog for product view. + + +0.8.79 (2020-01-06) +------------------- + +* Move "delete results" logic for master grid. + + +0.8.78 (2020-01-02) +------------------- + +* Add ``Grid.set_filters_sequence()`` convenience method. + +* Add dialog for viewing product SRP history. + + +0.8.77 (2019-12-04) +------------------- + +* Use currency formatting for costs in vendor catalog batch. + + +0.8.76 (2019-12-02) +------------------- + +* Allow update of row unit cost directly from receiving batch view. + +* Show vendor item code in receiving batch row grid. + +* Expose catalog cost, allow updating, for receiving batch rows. + +* Add API view for marking "receiving complete" for receiving batch. + +* Allow override of user authentication logic for API. + +* Add API views for admin user to become / stop being "root". + + +0.8.75 (2019-11-19) +------------------- + +* Filter by receiving mode, for receiving batch API. + + +0.8.74 (2019-11-15) +------------------- + +* Add support for label batch "quick entry" API. + +* Add support for "toggle complete" for batch API. + +* Add some API views for receiving, and vendor autocomplete. + +* Move "quick entry" logic for purchase batch, into rattail handler. + +* Provide background color when first checking API session. + + +0.8.73 (2019-11-08) +------------------- + +* Assume "local only" flag should be ON by default, for new objects. + +* Bump default Buefy version to 0.8.2. + +* Always store CSRF token for each page in Vue.js theme. + +* Refactor "make batch from products query" per Vue.js theme. + +* Add Vue.js support for "enable / disable selected" grid feature. + +* Add Vue.js support for "delete selected" grid feature. + +* Improve checkbox click handling support for grids. + +* Improve/fix some views for Messages per Vue.js theme. + +* Add some padding above/below form fields (for Vue.js). + +* Use "warning" status for pricing batch rows, where product not found. + +* Refactor "send new message" form, esp. recipients field, per Vue.js. + +* Allow rendering of "raw" datetime as ISO date. + +* Add very basic API views for label batches. + +* Fallback to referrer if form has no cancel button URL. + +* Fix merge feature for master index grid. + + +0.8.72 (2019-10-25) +------------------- + +* Allow bulk delete of New Product batch rows. + +* Don't bug out if can't update roles for user. + + +0.8.71 (2019-10-23) +------------------- + +* Improve default behavior for clone operation. + +* Add config flag to "force unit item" for inventory batch. + +* Fix JS bug for graph view of tempmon probe readings. + + +0.8.70 (2019-10-17) +------------------- + +* Don't bug out if stores, departments fields aren't present for Employee. + + +0.8.69 (2019-10-15) +------------------- + +* Fix buefy grid pager bug. + +* Fix permissions for add/edit/delete notes from people profile view. + + +0.8.68 (2019-10-14) +------------------- + +* Use ``self.has_perm()`` within MasterView. + +* Only show action URL if present, for Buefy grid rows. + +* Show active flag for users mini-grid on Role view page. + + +0.8.67 (2019-10-12) +------------------- + +* Fix URL for user, for feedback email. + +* Add "is false or null" verb for boolean grid filters. + +* Move label batch views to ``tailbone.views.batch.labels``. + +* Allow bulk-delete for some common batches. + +* Move vendor catalog batch views to ``tailbone.views.batch.vendorcatalog``. + +* Expose the "is preferred vendor" flag for vendor catalog batches. + +* Move vendor invoice batch views to ``tailbone.views.batch.vendorinvoice``. + +* Expose unit cost diff for vendor invoice batch rows. + +* Honor configured db key sequence; let config hide some db keys from UI. + + +0.8.66 (2019-10-08) +------------------- + +* Fix label bug for grid filter with value choices dropdown. + + +0.8.65 (2019-10-07) +------------------- + +* Add support for "local only" Person, User, plus related security. + + +0.8.64 (2019-10-04) +------------------- + +* Add ``forbidden()`` convenience method to core View class. + + +0.8.63 (2019-10-02) +------------------- + +* Fix "progress" behavior for upgrade page. + + +0.8.62 (2019-09-25) +------------------- + +* Add core ``View.make_progress()`` method. + + +0.8.61 (2019-09-24) +------------------- + +* Use ``simple_error()`` from rattail, for showing some error messages. + +* Honor kwargs used for ``MasterView.get_index_url()``. + +* Fix progress page so it effectively fetches progress data synchronously. + +* Show "image not found" placeholder image for products which have none. + + +0.8.60 (2019-09-09) +------------------- + +* Show product image from database, if it exists. + +* Let config turn off display of "POD" image from products. + + +0.8.59 (2019-09-09) +------------------- + +* Let a grid have custom ajax data url. + +* Set default max height, width for app logo. + +* Hopefully fix "single store" behavior when make a new ordering batch. + +* Add basic support for create and update actions in API views. + +* Tweak how we detect JSON request body instead of POST params. + +* Add basic support for "between" verb, for date range grid filter. + +* Add basic API view for user feedback. + +* Add basic API view for "about" page. + +* Include ``short_name`` in field list returned by /session API. + +* Return current user permissions when session is checked via API. + +* Tweak return value for /customers API. + +* Cleanup styles for login form. + +* Add /products API endpoint, enable basic filter support for API views. + +* Add basic API endpoints for /ordering-batch. + +* Don't show Delete Row button for executed batch, on jquery mobile site. + +* Include tax1 thru tax3 flags in form fields for product view page. + +* Prevent text wrap for pricing panel fields on product view page. + +* Fix rendering of "handheld batches" field for inventory batch view. + +* Fix various templates for generating reports, per Buefy. + +* Fix 'about' page template for Buefy themes. + + +0.8.58 (2019-08-21) +------------------- + +* Provide today's date as context for profile view. + +* Tweak login page logo style for jQuery (non-Buefy) themes. + + +0.8.57 (2019-08-05) +------------------- + +* Remove unused "login tips" for demo. + +* Fix form handling for user feedback. + +* Fix "last sold" field rendering for product view. + + +0.8.56 (2019-08-04) +------------------- + +* Fix home and login pages for Buefy theme. + + +0.8.55 (2019-08-04) +------------------- + +* Allow "touch" for Person records. + +* Refactor Buefy templates to use WholePage and ThisPage components. + +* Highlight former Employee records as red/warning. + + +0.8.54 (2019-07-31) +------------------- + +* Freeze Buefy version at pre-0.8.0. + + +0.8.53 (2019-07-30) +------------------- + +* Add proper support for composite primary key, in MasterView. + + +0.8.52 (2019-07-25) +------------------- + +* Add 'disabled' prop for Buefy datepicker. + +* Add perm for editing employee history from profile view. + +* Add "multi-engine" support for Trainwreck transaction views. + +* Cleanup 'phone' filter/sort logic for Employees grid. + + +0.8.51 (2019-07-13) +------------------- + +* Add basic "DB picker" support, for views which allow multiple engines. + +* Include employee history data in context for "view profile". + +* Add custom permissions for People "profile" view. + +* Use latest version of Buefy by default, for falafel theme. + +* Send URL for viewing employee, along to profile page template. + + +0.8.50 (2019-07-09) +------------------- + +* Add way to hide "view profile" helper for customer view. + +* Add ``render_customer()`` method for MasterView. + +* When creating an export, set creator to current user. + +* Add basic "downloadable" support for ExportMasterView. + +* Remove unwanted "export has file" logic for ExportMasterView. + +* Refactor feedback dialog for Buefy themes. + +* Add support for general "view click handler" for ```` element. + + +0.8.49 (2019-07-01) +------------------- + +* Fix product view template per Buefy refactoring. + + +0.8.48 (2019-07-01) +------------------- + +* Clear checked rows when refreshing async grid data. + + +0.8.47 (2019-07-01) +------------------- + +* Allow "touch" for customer records. + +* Add ``NumericInputWidget`` for use with Buefy themes. + +* Expose a way to embed "raw" data values within Buefy grid data. + +* Add 'duration_hours' type for grid column display. + +* Make sure grid action links preserve white-space. + + +0.8.46 (2019-06-25) +------------------- + +* Only expose "Make User" button when viewing a person. + +* Fix PO total calculation bug for mobile ordering. + +* Fix "edit row" icon for batch row grids, for Buefy themes. + +* Refactor all Buefy form submit buttons, per Chrome behavior. + + +0.8.45 (2019-06-18) +------------------- + +* Fix inheritance issue with "view row" master template. + + +0.8.44 (2019-06-18) +------------------- + +* Add generic ``/page.mako`` template. + +* Add Buefy support for "execute results" from core batch grid view. + +* Pull the grid tools to the right, for Buefy. + +* Fix click behavior for all/diffs package links in upgrade view. + +* Refactor form/page component structure for Buefy/Vue.js. + + +0.8.43 (2019-06-16) +------------------- + +* Refactor tempmon probe view template, per Buefy. + +* Refactor tempmon probe graph view per Buefy. + +* Use once-button for tempmon client restart. + +* Fix package diff table for upgrade view template, per Buefy. + +* Assign client IP address to session, for sake of data versioning. + +* Use locale formatting for some numbers in the Buefy grid. + +* Buefy support for "mark batch as (in)complete". + + +0.8.42 (2019-06-14) +------------------- + +* Fix some response headers per python 3. + +* Make person, created by fields readonly when editing Person Note. + + +0.8.41 (2019-06-13) +------------------- + +* Add ``json_response()`` convenience method for all views. + +* Add ```` element template for simple grids with "static" data. + +* Improve props handling for ```` component. + +* Fall back to parsing request body as JSON for form data. + +* Basic support for maintaining PersonNote data from profile view. + +* Fix permissions styles for view/edit of User, Role. + +* Turn on bulk-delete feature for Raw Settings view. + +* Add a generic "user" field renderer to master view. + +* Fix "current value" for ```` element in e.g. edit form views. + +* Use ```` in more places, where appropriate. + +* Update calculated PO totals for purchasing batch, when editing row. + +* Add support for Buefy autocomplete. + +* More Buefy tweaks, for file upload, and "edit batch" generally. + +* Tweak structure of "view product" page to support Buefy, context menu. + +* Add support for "simple confirm" of object deletion. + +* Add some vendor fields for product Excel download. + + +0.8.40 (2019-06-03) +------------------- + +* Add ``verbose`` flag for ``util.raw_datetime()`` rendering. + +* Add basic master view for PersonNote data model. + +* Make email preview buttons use primary color. + +* Add basic Buefy support for batch refresh, execute buttons. + +* Add basic/generic Buefy support to the Form class. + +* Add custom ``tailbone-datepicker`` component for Buefy. + +* Let view template define how to render "row grid tools". + +* Move logic used to determine if current request should use Buefy. + +* Allow inherited theme to set location of Vue.js, Buefy etc. + +* Add "full justify" for grid filter pseudo-column elements. + +* Expose per-page size picker for Buefy grids. + +* Add basic Buefy support for default SelectWidget template. + +* Add Buefy support for enum grid filters. + +* Add ```` component for Buefy templates. + +* Add basic Buefy support for "Make User" button when viewing Person. + +* Make Buefy grids use proper Vue.js component structure. + +* Assume forms support Buefy if theme does; fix basic CRUD views. + +* Fix Buefy "row grids" when viewing parent; add basic file upload support. + +* Refactor "edit printer settings" view for Label Profile. + +* Add Buefy panels support for "view product" page. + +* Allow bulk row delete for generic products batch. + +* also "lots more changes" for sake of Buefy support... + + +0.8.39 (2019-05-09) +------------------- + +* Expose params and type key for report output. + +* Clean up falafel theme, move some parts to root template path. + +* Allow choosing report from simple list, when generating new. + +* Force unicode string behavior for left/right arrow thingies. + +* Must still define "jquery theme" for falafel theme, for now. + +* Add support for "quickie" search in falafel theme. + +* Fix sorting info bug when Buefy grid doesn't support it. + +* Make "view profile" buttons use "primary" color. + +* Add ``simple_field()`` def for base falafel template. + +* Align pseudo-columns for grid filters; let app settings define widths. + +* Tweak how we disable grid filter options. + +* Add basic Buefy form support when generating reports. + +* Add basic/generic email validator logic. + + +0.8.38 (2019-05-07) +------------------- + +* Add basic support for "quickie" search. + +* Add basic Buefy support for row grids. + +* Add basic Buefy support for merging 2 objects. + + +0.8.37 (2019-05-05) +------------------- + +* Add basic Buefy support for full "profile" view for Person. + + +0.8.36 (2019-05-03) +------------------- + +* Add basic support for "touching" a data record object. + + +0.8.35 (2019-04-30) +------------------- + +* Add filter for Vendor ID in Pricing Batch row grid. + +* Pass batch execution kwargs when doing that via subprocess. + + +0.8.34 (2019-04-25) +------------------- + +* Don't assume grid model class declares its title. + + +0.8.33 (2019-04-25) +------------------- + +* Add "most of" Buefy support for grid filters. + +* Add Buefy support for email preview buttons. + +* Improve logic used to determine if current theme supports Buefy. + +* Add basic Buefy support for App Settings page. + +* Add views for "new product" batches. + +* Fix auto-disable action for new message form. + +* Declare row fields for vendor catalog batches. + +* Add "created by" and "executed by" grid filters for all batch views. + +* Expose new code fields for pricing batch. + +* Add basic Buefy support for "find user/role with permission X". + +* Improve default people "profile" view somewhat. + +* Add support for generic "product" batch type. + +* Fix some issues with progress "socket" workaround for batches. + +* Allow config to specify grid "page size" options. + +* Add ``render_person()`` convenience method for MasterView. + + +0.8.32 (2019-04-12) +------------------- + +* Can finally assume "simple" menus by default. + +* Add custom grid filter for phone number fields. + +* Add ``raw_datetime()`` function to ``tailbone.helpers`` module. + +* Add "profile" view, for viewing *all* details of a given person at once. + +* Add "view profile" object helper for all person-related views. + +* Hopefully fix style bug when new filter is added to grid. + + +0.8.31 (2019-04-02) +------------------- + +* Require invoice parser selection for new truck dump child from invoice. + +* Make sure user sees "receive row" page on mobile, after scanning UPC. + +* Use shipped instead of ordered, for receiving authority. + +* Add ``move_before()`` convenience method for ``GridFilterSet``. + + +0.8.30 (2019-03-29) +------------------- + +* Add smarts for some more projects in the upgraded packages links. + +* Add basic "Buefy" support for grids (master index view). + +* Remove 'number' column for Customers grid by default. + +* Add feature for generating new report of arbitrary type and params. + +* Fix rendering bug when ``price.multiple`` is null. + +* Fix HTML escaping bug when rendering products with pack price. + +* Don't allow deletion of some receiving data rows on mobile. + +* Add validation when "declaring credit" for receiving batch row. + +* Add proper hamburger menu for falafel theme. + +* Add icon for Feedback button, in falafel theme. + + +0.8.29 (2019-03-21) +------------------- + +* Allow width of object helper panel to grow. + + +0.8.28 (2019-03-14) +------------------- + +* Tweak how batch handler is invoked to remove row. + +* Add mobile alert when receiving product for 2nd time. + +* Honor enum sort order where possible, for grid filter values. + +* Add basic "receive row" desktop view for receiving batches. + +* Add "declare credit" UI for receiving batch rows. + + +0.8.27 (2019-03-11) +------------------- + +* Fix some unicode literals for base template. + + +0.8.26 (2019-03-11) +------------------- + +* Expose "true cost" and "true margin" columns for products grid. + +* Use configured background color for 'bobcat' theme. + +* Add view, edit links to vue.js users index. + +* Fix navbar, footer background to match custom body background (bobcat theme). + +* Fix layout issues for bobcat theme, so footer sticks to bottom. + +* Fix login page styles for bobcat theme. + +* Refactor template ``content_title()`` and prev/next buttons feature. + +* Add basic 'dodo' theme. + +* Allow apps to set background color per request. + +* Add 'falafel' theme, based on bobcat. + +* Begin to customize grid filters, for 'falafel' theme. + +* Fix PO unit cost calculation for ordering row, batch. + + +0.8.25 (2019-03-08) +------------------- + +* Show grid link even when value is "false-ish". + +* Only objectify address data if present. + +* Improve display of purchase credit data. + +* Expose new "calculated" invoice totals for receiving batch, rows. + + +0.8.24 (2019-03-06) +------------------- + +* Add "plain" date widget. + +* Invoke handler when marking batch as (in)complete. + +* Add new "receive row" view for mobile receiving; invokes handler. + +* Remove 'truck_dump' field from mobile receiving batch view. + +* Add "truck dump status" fields to receiving batch views. + +* Add ability to sort by Credits? column for receiving batch rows. + +* Add mobile support for basic "feedback" dialog. + +* Tweak the "incomplete" row filter for mobile receiving batch. + + +0.8.23 (2019-02-22) +------------------- + +* Add basic support for "mobile edit" of records. + +* Add basic support for editing address for a "contact" record. + +* Add ``unique_id()`` validator method to Customer view. + +* Declare "is contact" for the Customers view. + +* Allow vendor field to be dropdown, for mobile ordering/receiving. + +* Treat empty string as null, for app settings field values. + + +0.8.22 (2019-02-14) +------------------- + +* Improve validator for "percent" input widget. + +* Refactor email settings/preview views to use email handler. + + +0.8.21 (2019-02-12) +------------------- + +* Remove usage of ``colander.timeparse()`` function. + + +0.8.20 (2019-02-08) +------------------- + +* Introduce support for "children first" truck dump receiving. + + +0.8.19 (2019-02-06) +------------------- + +* Add support for downloading batch rows as XLSX file. + + +0.8.18 (2019-02-05) +------------------- + +* Add support for "delete set" feature for main object index view. + +* Use app node title setting for base template. + +* Improve user form handling, to prevent unwanted Person creation. + +* Add support for background color app setting. + +* Add generic support for "enable/disable selection" of grid records. + + +0.8.17 (2019-01-31) +------------------- + +* Improve rendering of ``enabled`` field for tempmon clients, probes. + + +0.8.16 (2019-01-28) +------------------- + +* Update tempmon UI now that ``enabled`` flags are really datetime in DB. + + +0.8.15 (2019-01-24) +------------------- + +* Fix response header value, per python3. + + +0.8.14 (2019-01-23) +------------------- + +* Use empty string for "missing" department name, for ordering worksheet. + + +0.8.13 (2019-01-22) +------------------- + +* Include ``robots.txt`` in the manifest. + + +0.8.12 (2019-01-21) +------------------- + +* Log details of one-off label printing error, when they occur. + +* Fix Excel download of ordering batch, per python3. + + +0.8.11 (2019-01-17) +------------------- + +* Convert all datetime values to localtime, for "download rows as CSV". + + +0.8.10 (2019-01-11) +------------------- + +* Fix products grid query when filter/sort has multiple ProductCost joins. + + +0.8.9 (2019-01-10) +------------------ + +* Tweak batch view template "object helpers" for easier customization. + +* Let batch view customize logic for marking batch as (in)complete. + +* Make command configurable, for restarting tempmon-client. + + +0.8.8 (2019-01-08) +------------------ + +* Add custom widget for "percent" field. + + +0.8.7 (2019-01-07) +------------------ + +* Fix styles for master view_row template. + +* Turn off messaging-related menus by default. + + +0.8.6 (2019-01-02) +------------------ + +* Expose ``vendor_id`` column in pricing batch row grid. + +* Only allow POST method for executing "results" for batch grid. + + +0.8.5 (2019-01-01) +------------------ + +* Add basic master view for Members table. + + +0.8.4 (2018-12-19) +------------------ + +* Add ``object_helpers()`` def to master/view template. + +* Add ``oneoff_import()`` helper method to MasterView class. + +* Fix some styles, per flexbox layout changes. + +* Add ability to make new pricing batch from input data file. + +* Clean up some inventory batch UI logic; prefer units by default. + +* Add 'unit_cost' to Excel download for Products grid. + +* Expose subdepartment for pricing batch rows. + +* Add 'percent' as field type for Form; fix rendering of 'percent' for Grid. + +* Expose label profile selection when editing label batch. + +* Make sure custom field labels are shown for batch execution dialog. + + +0.8.3 (2018-12-14) +------------------ + +* Fix some layout styles for master edit template. + + +0.8.2 (2018-12-13) +------------------ + +* Refactor product view template to use flexbox styles. + + +0.8.1 (2018-12-10) +------------------ + +* Expose new "sync me" flag for LabelProfile settings. + + +0.8.0 (2018-12-02) +------------------ + +This version begins the "serious" efforts in pursuit of REST API, Vue.js, Bulma +and related technologies. + +* Use sqlalchemy-filters package for REST API collection_get. + +* Refactor API collection_get to work with vue-tables-2. + +* Remove some relationship fields when creating new Person. + +* Fix bug in receiving template when truck dump not enabled. + +* Tweak default "model title" logic for master view. + +* Add better support for "make import batch from file" pattern. + +* Fix download filename when it contains spaces. + +* Add "min % diff" option for pricing batch from products query. + +* Allow override of products query when making batch from it. + +* Use empty string instead of null as fallback value, for pricing rows CSV. + +* Add very basic Vue.js grid/index experiment for Users table. + +* Add patterns for joining tables in API list methods. + +* Add template "theme" feature, albeit global. + +* Clean up how we configure DB sessions on app startup. + +* Add description, notes to default form_fields for batch views. + +* Add basic 'excite-bike' theme. + +* Use Bulma CSS and some components for 'bobcat' theme. + +* Add basic support for "simple menus". + +* Refactor default theme re: "context menu" and "object helper" styles. + +* Use 4 decimal places when calculating hours for worked shift excel download. + +* Expose ``old_price_margin`` field for pricing batch rows. + + +0.7.50 (2018-11-19) +------------------- + +* Add simple price fields for product XLSX results download. + +* Add "200 per page" option for UI table grids. + +* Add department, subdepartment "name" columns for products XLSX download. + +* Allow override of template for custom create views. + +* Expose new ``Customer.wholesale`` flag. + +* Add vendor id, name to row CSV download for pricing batch. + +* Expose ``suggested_price``, ``price_diff_percent``, ``margin_diff`` for + pricing batch row. + + +0.7.49 (2018-11-08) +------------------- + +* Detect non-numeric entry when locating row for purchase batch. + +* Remove unwanted style for "email setting description" field. + +* Add ``Grid.hide_columns()`` convenience method. + +* Make sure status field is readonly when creating new batch. + +* Display "suggested price" when viewing product details. + + +0.7.48 (2018-11-07) +------------------- + +* Add initial ``tailbone.api`` subpackage, with some basic API views. Note + that this API is meant to be ran as a separate app so we can better leverage + Cornice features. + +* Add client IP address to user feedback email. + + +0.7.47 (2018-10-25) +------------------- + +* Try to configure the 'pyramid_retry' package, if available. + +* Add more time range options for viewing tempmon probe readings as graph. + +* Add button for restarting filemon. + + +0.7.46 (2018-10-24) +------------------- + +* Allow individual App Settings to not be required; allow null. + +* Add ``MasterView.render_product()``; fix edit for pricing batch row. + +* Add ability to "transform" TD parent row from pack to unit item. + + +0.7.45 (2018-10-19) +------------------- + +* Add very basic support for viewing tempmon probe readings as graph. + + +0.7.44 (2018-10-19) +------------------- + +* Don't include LargeBinary properties in default colander schema. + + +0.7.43 (2018-10-19) +------------------- + +* Add new timeout fields for tempmon probe. + +* Customize template for viewing probe details. + +* Add support for new Tempmon Appliance table, etc. + +* Add basic image upload support for tempmon appliances. + +* Add thumbnail images to Appliances grid. + +* Hopefully, let the Grid class generate a default list of columns. + +* Don't include grid filters for LargeBinary columns. + + +0.7.42 (2018-10-18) +------------------- + +* Fix a dialog button for Chrome. + + +0.7.41 (2018-10-17) +------------------- + +* Cache user permissions upon "new request" event. + +* Add basic Excel download support for Products table. + + +0.7.40 (2018-10-13) +------------------- + +* Add "hours as decimal" hover text for some HH:MM timesheet values. + + +0.7.39 (2018-10-09) +------------------- + +* Fix bug when non-numeric entry given for mobile inventory "quick row". + +* Show tempmon readings when viewing client or probe. + +* Auto-disable button when sending email preview. + +* Add some helptext for various tempmon fields. + +* Allow override of jquery for base templates, desktop and mobile. + +* Improve "length" (hours) column for Worked Shifts grid. + +* Add basic Excel download support for raw worked shifts. + + +0.7.38 (2018-10-03) +------------------- + +* Add support for "archived" flag in Tempmon Client views. + +* Expose notes field for tempmon client and probe views. + +* Expose new ``disk_type`` field for tempmon client views. + +* Tweak how receiving rows are looked up when adding to the batch. + + +0.7.37 (2018-09-27) +------------------- + +* Restrict (temporarily I hope) webhelpers2_grid to 0.1. + + +0.7.36 (2018-09-26) +------------------- + +* Leverage alternate code also, for mobile product quick lookup. + +* Misc. UI improvements for truck dump receiving on desktop. + +* Add speedbump by default when deleting any "row" record. + +* Expose ``item_entry`` field for receiving batch row. + +* Capture user input for mobile receiving, and move some lookup logic. + + +0.7.35 (2018-09-20) +------------------- + +* Fix batch row status breakdown, for rows with no status. + + +0.7.34 (2018-09-20) +------------------- + +* Add unique check for "name" when creating new Role. + +* Fix bug when editing truck dump child batch row quantities. + +* Add setting to show/hide product image for mobile purchasing/receiving. + +* Show red background for mobile receiving if product not found. + +* Add quick-receive 1EA, 3EA, 6EA for mobile receiving. + +* Fix how we check config for mobile "quick receive" feature. + +* Do quick lookup by vendor item code, alt code for mobile receiving. + +* Fix price fields, add pref. vendor/cost fields for mobile product view. + +* Add simple row status breakdown when viewing batch. + +* Only show mobile "quick receive" buttons if product is identifiable. + + +0.7.33 (2018-09-10) +------------------- + +* Fix default (status) filter for Employees grid. + + +0.7.32 (2018-08-24) +------------------- + +* Add "quick receive all" support for mobile receiving. + +* Refactor sqlerror tween to add support for pyramid_retry. + +* Honor view logic when displaying Delete Row button for mobile receiving. + + +0.7.31 (2018-08-14) +------------------- + +* Make sure we refresh batch status when adding a new row. + +* Hide 'ordered' columns for truck dump parent row grid. + +* Add support for editing "claim" quantities for truck dump child row. + +* Use invoice total, PO total as fallback, for mobile receiving list. + +* Show links to claiming rows for truck dump parent row. + +* Add "quick lookup" for mobile Products page. + + +0.7.30 (2018-07-31) +------------------- + +* Don't configure versioning when making the app. + + +0.7.29 (2018-07-30) +------------------- + +* Various tweaks for arbitrary model view with "rows". + + +0.7.28 (2018-07-26) +------------------- + +* Let mobile form declare if/how to auto-focus a field. + +* Assign purchase to new receiving batch via uuid instead of object ref. + +* Fix permission group label for Ordering Batches. + +* Redirect to "view parent" after deleting a row. + + +0.7.27 (2018-07-19) +------------------- + +* Use upload time as default filter/sort for Trainwreck transactions. + +* Add initial support for mobile "quick row" feature, for ordering. + +* Add product grid filters for "on hand", "on order". + +* Don't make customer ID readonly when editing. + +* Fix Person.customers readonly field for python 3. + +* Traverse master class hierarchy to collect all defined labels. + +* Add 'person' column for customers grid. + +* Fix how we check file size when reading stdout for upgrade. + +* Add runtime ``mobile`` flag for ``MasterView``. + +* Improve basic mobile views for customers, people. + +* Refactor mobile receiving to use "quick row" feature. + +* Improve support for "receive from scratch" workflow, esp. for mobile. + +* Add (admin-friendly!) view to manage some App Settings. + +* Add (restore?) basic support for mobile receiving from PO. + +* Expose status etc. when editing upgrade; rename Email Settings. + + +0.7.26 (2018-07-11) +------------------- + +* Force user to count "units" and not "packs" for inventory batch. + +* Fix bug for inventory batch when product not found. + +* Sort mobile receiving rows by last modified instead of sequence. + +* Tweak default page title for master view. + +* Show "truck dump" info for applicable receiving batch page title. + +* Highlight purchasing batch rows with "case quantity differs" status. + +* Improve how cases/units, uom are handled for mobile receiving. + +* Add "?" for daily time sheet total if partial shift present. + +* Fix cancel button for progress page. + + +0.7.25 (2018-07-09) +------------------- + +* Fix enum values for customer email preference grid filter. + +* Tweak field ordering for customer form. + +* Remove deprecated "edbob" settings. + +* Improve basic support for unit/pack info when viewing product details. + + +0.7.24 (2018-07-03) +------------------- + +* Tweak how some "pack item" fields are displayed when viewing product. + + +0.7.23 (2018-07-03) +------------------- + +* Don't read upgrade progress file if size hasn't changed. + +* Fix batch file download link URL. + +* Fix batch action kwargs, so 'action' can be a handler kwarg. + + +0.7.22 (2018-06-29) +------------------- + +* Consider any integer greater than PG allows, to be invalid grid filter value. + + +0.7.21 (2018-06-28) +------------------- + +* Fix bug when populating new batch. + +* Allow zero quantity for inventory batch rows. + +* Allow editing of unit cost for inventory batch row. + +* Add overflow validation for cases/units in inventory batch desktop form. + +* Add ``credit_total`` column for purchase credits grid. + +* Don't aggregate product for mobile truck dump receiving. + +* Be smarter about when we sort receiving batch by most recent (for mobile). + +* Accept invoice number when adding truck dump child from invoice file. + +* Add highlight for "cost not found" rows in purchasing batch. + +* Fix email preview logic per python 3. + +* Improve basic support for adding new product. + +* Show department column for receiving batch rows. + +* Fix how "unknown product" row is added to receiving batch. + + +0.7.20 (2018-06-27) +------------------- + +* Fix input validation for integer grid filter. + + +0.7.19 (2018-06-14) +------------------- + +* Change how date fields are handled within grid filters. + +* Add workaround for using pip 10.0 "internal" API in upgrades view. + + +0.7.18 (2018-06-14) +------------------- + +* Auto-size columns for Excel results download. + +* Add Excel results download for categories, report codes. + +* Use "known" label if possible when making new grid filters. + +* Expose new ``exempt_from_gross_sales`` flags. + + +0.7.17 (2018-06-09) +------------------- + +* Allow products view to set some labels in costs grid. + +* Let config override ``sys.prefix`` when launching batch commands in subprocess. + + +0.7.16 (2018-06-07) +------------------- + +* Add versioning workaround support for batch actions. + + +0.7.15 (2018-06-05) +------------------- + +* Add integer-specific grid filter. + +* Set filter value renderer when setting enum for grid field. + + +0.7.14 (2018-06-04) +------------------- + +* Show department instead of subdept by default, for products grid. + +* Add support for variance inventory batches, aggregation by product. + +* Set default column renderers for grid based on data types. + +* Expose 'hidden' flag for inventory adjustment reasons. + +* Expose new ``Vendor.abbreviation`` field. + + +0.7.13 (2018-05-31) +------------------- + +* Show 'variance' field when viewing inventory batch row. + + +0.7.12 (2018-05-30) +------------------- + +* Make sure count mode is preserved when making new inventory batch. + +* Add initial support for "variance" inventory batch mode. + +* Fix handling of (missing) password when user is edited. + + +0.7.11 (2018-05-25) +------------------- + +* Add ``Form.__contains__()`` method. + +* Improve default behavior for receiving a purchase batch. + +* Fix label profile type field when editing label batch row. + +* Allow lookup of inventory item by alternate code. + +* Fix rowcount bug when first row added via ordering worksheet. + +* Add "most of" support for truck dump receiving. + +* Add docs for ``MasterView.help_url`` and ``get_help_url()``. + +* Add "Receive 1 CS" button for better efficiency in mobile receiving. + +* Add category name filter for products grid. + +* Increase allowed width for form labels. + +* Add ``allow_zero_all`` flag for inventory batch master. + +* Add buttons to toggle batch 'complete' flag when viewing batch. + +* Hide "create new row" link for batches which are marked complete. + +* Add way to prevent "case" entries for inventory adjustment batch. + +* Add ``MasterView.use_byte_string_filters`` flag for encoding search values. + + +0.7.10 (2018-05-02) +------------------- + +* Add sort/filter for department name, for Categories grid. + + +0.7.9 (2018-04-12) +------------------ + +* Add future mode for vendor catalog batch. + + +0.7.8 (2018-04-09) +------------------ + +* Add awareness for ``Email.dynamic_to`` flag in config UI. + +* Add new vendor catalog row status, render product with hyperlink. + + +0.7.7 (2018-03-23) +------------------ + +* Use 'today' as fallback order date for ordering worksheet. + +* Treat unknown UPC as "product not found" for inventory batch. + +* Refactor inventory batch desktop lookup, to allow for Type 2 UPC logic. + +* Fix default selection bug for store/department time sheet filters. + + +0.7.6 (2018-03-15) +------------------ + +* Fix text area behavior for email recipient fields. + +* Fix autodisable button bug for forms marked as such. + + +0.7.5 (2018-03-12) +------------------ + +* Add desktop support for creating inventory batches. + +* Expose vendor item code for purchase credits. + +* Fix default create logic for vendors, products. + +* Add changelog link for rattail-tempmon in upgrade diff. + +* Add ``disable_submit_button()`` global JS function. + +* Add basic support for making new product on-the-fly during mobile ordering. + + +0.7.4 (2018-02-27) +------------------ + +* Use all "normal" product form fields, for mobile view. + +* Refactor ordering worksheet to use shared logic. + +* Add download path for batch master views. + +* Add basic mobile support for executing batches (with options). + +* Add ``NumberInputWidget`` for ````. + +* Add ``Form.mobile`` flag and set link button styles accordingly. + +* Always show flash-error-style message when form has errors. + +* Use ``Form.submit_label`` if present, or fall back to ``save_label``. + +* Expose ``ship_method`` and ``notes_to_vendor`` for purchase, ordering batch. + +* Bind batch to its execution options schema, when applicable. + +* Don't set order date for new ordering batch when created via mobile. + +* Don't allow row deletion if batch is marked complete. + +* Add logic for editing default phone/email in base master view. + +* Fix bug in users view when person field not present. + + +0.7.3 (2018-02-15) +------------------ + +* More tweaks for python 3. + + +0.7.2 (2018-02-14) +------------------ + +* Refactor all remaining forms to use colander/deform. + +* Coalesce 'forms2' => 'forms' package. + +* Remove dependencies: FormAlchemy, FormEncode, pyramid_simpleform, pyramid_debugtoolbar + +* Misc. cleanup for Python 3. + +* Add generic 'login_as_home' setting. + +* Add tailbone version to base stylesheet URLs. + + +0.7.1 (2018-02-10) +------------------ + +* Make it easier to hide buttons for a form. + +* Let forms choose *not* to auto-disable their cancel button. + +* Add 'newstyle' behavior for ``Form.validate()``. + +* Add some basic ORM object field types for new forms. + +* Make sure each grid has unique set of actions. + +* Add 'gridcore' jQuery plugin, for core behavior. + +* Allow passing arbitrary attrs when rendering grid. + +* Refactor mobile receiving to use colander/deform. + +* Refactor mobile inventory to use colander/deform. + +* Refactor user login, change password to use colander/deform. + +* Fix some bugs with importer batch views. + + +0.7.0 (2018-02-07) +------------------ + +* Coalesce all master views back to single base class. + +* Add ``append()`` and ``replace()`` methods for core Grid class. + +* Show year dropdown by default for jQuery UI date pickers. + +* Don't process file for new batch unless field is present. + +* Add setting for "force home" mobile behavior. + +* Add 'plain' and 'jquery' templates for deform select widget. + +* Add "hidden" concept for form fields. + +* Add ``Form.show_cancel`` flag, for hiding that button. + +* Let each form define its "save" button text. + +* Add master view for ``EmailAttempt``. + +* Avoid "auto disable" button logic for new message form. + +* Add better UPC validation for mobile receiving. + + +0.6.69 (2018-02-01) +------------------- + +* Add proper enum for inventory batch "count mode" filter. + +* Fix bugs when making inventory batch on mobile. + + +0.6.68 (2018-01-31) +------------------- + +* Cap zope.sqlalchemy dependency at pre-1.0. + + +0.6.67 (2018-01-30) +------------------- + +* Fix permission bug when adding row in mobile receiving. + +* Fix mobile logout behavior. + +* Always redirect to mobile home page, if "other" page is refreshed. + + +0.6.66 (2018-01-29) +------------------- + +* Add support for detaching Person from Customer. + +* Allow disabling auto-dismiss of flash messages on mobile. + +* Add ``FieldList`` wrapper for grid columns list. + +* Show "unit cost" column by default, for products grid. + +* Improve case/unit quantity validation for order worksheet. + +* Show new 'confirmed' field for brands table. + +* Add support for extra column(s) in timesheet view table. + +* Add generic "download results as XLSX" feature. + +* Add vendor links in cost grid when viewing product. + +* Show "buttons" when viewing an object, with forms2 (i.e. Execute Batch). + +* Refactor "most" remaining batch views etc. to use master3. + + +0.6.65 (2018-01-24) +------------------- + +* Fix some master3 edit issues for products view. + +* Let custom inventory batch view override logic for mobile UPC scanning. + +* Show new ``cashback`` field for Trainwreck transaction. + +* Add 'delete-instance' class to delete link when viewing a record. + + +0.6.64 (2018-01-22) +------------------- + +* Warn if user "scans" UPC with more than 14 digits, for mobile inventory. + +* Add option for preventing new inventory batch rows for unknown products. + +* Add ``creates_multiple`` flag for master view. + +* Add basic support for per-page help URL. + + +0.6.63 (2018-01-16) +------------------- + +* Fix bug when locating association proxy column. + +* Fix client field when creating / editing tempmon probe. + +* Allow editing of inventory batch count mode and reason code. + + +0.6.62 (2018-01-11) +------------------- + +* Fix dialog button click event when executing price batch (for Chrome). + +* Fix some mobile view URLs. + +* Show case quantity for inventory batch rows. + +* Let custom schema node start out with empty children. + +* Allow passing None to ``Form.set_renderer()``. + + +0.6.61 (2018-01-11) +------------------- + +* Provide some default readonly form field renderers. + +* Fix row query bug when deleting batch row. + + +0.6.60 (2018-01-10) +------------------- + +* Refactor several straggler views to use master3. + +* Add first attempt at master3 for batch views. + + +0.6.59 (2018-01-08) +------------------- + +* Fix bug when printing product label. + + +0.6.58 (2018-01-08) +------------------- + +* Tweak diff styles when viewing upgrade. + + +0.6.57 (2018-01-07) +------------------- + +* Fix some styles for execution options dialog. + +* Show 'static_prices' flag for label batches. + +* Add field name as wrapper class name. + +* Change how select menus are enhanced for batch exec options. + +* Add view for InventoryAdjustmentReason model. + +* Stop setting execution details when multiple batches executed. + +* Add empty default when displaying values in grid. + +* Let grids be paginated even when they have no model class. + +* Exclude JS for refreshing batch unless it's relevant. + +* Tweak conditions for CSV row download link. + +* Add basic support for row grid view links. + +* Refactor away the ``row_route_prefix`` concept. + +* Add ``row_title`` to template context for ``view_row``. + +* Tweak ``diffs.css`` and refactor 'view_version' template to use it. + +* Add basic UI support for "importer batch" feature. + + +0.6.56 (2018-01-05) +------------------- + +* Fix bug when making batch from product query. + + +0.6.55 (2018-01-04) +------------------- + +* Add "price required" flag to product view. + +* Add a bit more flexibility to jquery time input values. + +* Show row count field when viewing vendor catalog batch. + +* Tweak product filter for report code name. + +* Refactor forms logic when making batch from product query. + + +0.6.54 (2017-12-20) +------------------- + +* Provide sane width for filter value dropdowns. + + +0.6.53 (2017-12-19) +------------------- + +* Accept ``value_enum`` kwarg when creating grid filter. + + +0.6.52 (2017-12-08) +------------------- + +* Add transaction "System ID" field for Trainwreck. + +* Add ``Grid.set_sort_defaults()`` method. + +* Change template prefix for vendor catalog batches. + +* Add basic "helptext" support for forms2. + +* Add cleared/selected callbacks for jquery autocomplete in forms2. + +* Add ``Grid.remove_filter()`` method. + +* Add custom schema type for jQuery time picker data. + +* Refactor lots of views to use master3. + + +0.6.51 (2017-12-03) +------------------- + +* Refactor customers view to use master3. + +* Add custom ``FieldList`` class for forms2 field list. + +* Auto-scroll window as needed to ensure drop-down choices are visible. + +* Hide status when creating new purchasing batch. + +* Add "manually priced" awareness to pricing batch UI. + +* Add batch description to page body title. + +* Fix batch row count when bulk-deleting rows. + +* Allow bulk delete of label batch rows. + +* Expose description and notes for label batches. + +* Let batch views allow or deny "execute results" option. + +* Allow "execute results" for inventory batches. + +* Fix permission bug for mobile inventory batch. + +* Expose default address for customers view. + + +0.6.50 (2017-11-21) +------------------- + +* Set widget when defining enum for a form2 field. + +* Add date/time-picker, autocomplete support for forms2 (deform). + +* Add colander magic for association proxy fields. + + +0.6.49 (2017-11-19) +------------------- + +* Improve auto-disable logic for some form buttons. + +* Fix (hack) for editing some department flags. + + +0.6.48 (2017-11-11) +------------------- + +* Accept ``None`` as valid arg for ``Grid.set_filter()``. + + +0.6.47 (2017-11-08) +------------------- + +* Fix manifest to include ``*.pt`` deform templates + + +0.6.46 (2017-11-08) +------------------- + +* Add ``json`` to global template context + + +0.6.45 (2017-11-01) +------------------- + +* Add product and personnel flags for Department + +* Add sorters, filters for Product regular, current price + +* Add "text" type for new form fields + +* Add description, notes for pricing batches + + +0.6.44 (2017-10-29) +------------------- + +* Fix join bug for Upgrades table when sorting by executor + + +0.6.43 (2017-10-29) +------------------- + +* Add "make user" button when viewing person w/ no user account + + +0.6.42 (2017-10-28) +------------------- + +* Add cashier info, upload time for Trainwreck transaction views + + +0.6.41 (2017-10-25) +------------------- + +* Add support for validator and required flag, for new forms + +* Use master3 view for datasync changes + + +0.6.40 (2017-10-24) +------------------- + +* Add grid filter which treats empty string as NULL + +* Fix value auto-selection for enum grid filters + +* Add ``item_id`` to trainwreck views + +* Expose ``Person.users`` relationship (readonly) + + +0.6.39 (2017-10-20) +------------------- + +* Fix bug with products view config + + +0.6.38 (2017-10-19) +------------------- + +* Add "local" datetime renderer for new grids, forms + +* Make CSRF protection optional (but on by default) + +* Convert user feedback mechanism to use modal dialog + +* Add 'active' column to Users table view + +* Add "download row results as CSV" feature to master view + +* Add support for setting default field values on new forms + +* Add 'currency' field type for new forms + +* Allow passing ``None`` to ``Grid.set_joiner()`` + + +0.6.37 (2017-09-28) +------------------- + +* Fix data type/size issue with CSV download + +* Don't set batch input file on creation, if no file exists + +* Add "auto-enhance" select field template for deform + +* Add ability to override schema node for custom deform fields + +* Fix deform widget resource inclusion for master/create template + +* Pass form along to ``before_create_flush()`` in master3 + +* Add "populatable" for master views (populating new objects with progress) + +* Add 'duration' type for new form fields + + +0.6.36 (2017-09-15) +------------------- + +* Fix user field rendering when no person associated + +* Add generic support for downloading list results as CSV + +* Tweak title for master view row template + + +0.6.35 (2017-08-30) +------------------- + +* Fix some bugs for rendering upgrade package diffs + + +0.6.34 (2017-08-18) +------------------- + +* Fix mobile inventory template + +* Add extra perms for creating inventory batch w/ different modes + +* Allow batch execution to require options on a per-batch basis + +* Convert more views to master3: + departments, subdepartments, categories, brands, bouncer, customer groups + +* Override deform template for checkbox field; fix label behavior + +* Show all grid actions by default, if there are 3 or less + +* Use shared logic for executing upgrade + + +0.6.33 (2017-08-16) +------------------- + +* Add ``LocalDateTimeFieldRenderer`` for formalchemy + +* Fix auto-disable button on form submit, per Chrome issues + + +0.6.32 (2017-08-15) +------------------- + +* Add generic changelog link for rattail/tailbone packages + +* Let handler delete files when deleting upgrade + +* Add mechanism for user to bulk-change status for purchase credits + +* Tweak how pyramid config is created during app startup, for tests + +* Fix permission used for mobile receiving item lookup + + +0.6.31 (2017-08-13) +------------------- + +* Add show all vs. show diffs for upgrade packages + +* Add initial support for changelog links for upgrade package diffs + +* Add prev/next buttons when viewing upgrade details + +* Merge 'better' theme into base templates + + +0.6.30 (2017-08-12) +------------------- + +* Make product field renderer allow override of link text rendering + + +0.6.29 (2017-08-11) +------------------- + +* Various tweaks to inventory batch logic (zero-all mode etc.) + +* Fix join bug for users grid + +* Flush session once every 1000 records when bulk-deleting + + +0.6.28 (2017-08-09) +------------------- + +* Fix clone config bug for label batches + + +0.6.27 (2017-08-09) +------------------- + +* Improve inventory support, plus "hiding" person data while still using it + +* Fix encoding bug when reading stdout during upgrade + + +0.6.26 (2017-08-09) +------------------- + +* Add awareness of upgrade exit code, success/fail + +* Add support for cloning an upgrade record + +* Add running display of stdout.log when executing upgrade + + +0.6.25 (2017-08-08) +------------------- + +* Specify ``expire_on_commit`` for tailbone db session + + +0.6.24 (2017-08-08) +------------------- + +* Fix bug which caused new empty worked shift when editing time sheet + + +0.6.23 (2017-08-08) +------------------- + +* Fix bulk-delete for batch rows, allow it for pricing batches + +* Fix permission check for deleting single batch rows + +* Fix numeric filter to allow 3 decimal places by default + + +0.6.22 (2017-08-08) +------------------- + +* Remove unwanted import (which broke versioning) + +* Add some links to employees grid + + +0.6.21 (2017-08-08) +------------------- + +* Refactor progress bars somewhat to allow file-based sessions + +* Fix recipients renderer for email settings grid + +* Improve status tracking for upgrades; add package version diff + + +0.6.20 (2017-08-07) +------------------- + +* Record become/stop root user events + +* Make datasync changes bulk-deletable + +* Add basic support for performing / tracking app upgrades + + +0.6.19 (2017-08-04) +------------------- + +* Record basic user login/logout events + +* Expose UserEvent table in UI + + +0.6.18 (2017-08-04) +------------------- + +* Add progress support for bulk deletion + +* Make tempmon readings bulk-deletable + + +0.6.17 (2017-08-04) +------------------- + +* Various view tweaks + + +0.6.16 (2017-08-04) +------------------- + +* Add auto-links for most grids + +* Fix row highlighting for sources panel on product view + + +0.6.15 (2017-08-03) +------------------- + +* Allow product field renderer to suppress hyperlink + +* Add 'data-uuid' attr for mobile grid list items, if applicable + +* Initial (partial) support for mobile ordering + +* Some tweaks to ordering batch views + +* Fix bug when request.user becomes unattached from session (?) + +* Add view for consuming new batch ID + +* Add some links to various grid columns + +* Fix bug in master view_row + + +0.6.14 (2017-08-01) +------------------- + +* Make login template use same logo as home page + +* Fix how we detect grid settings presence in user session + +* Improve verbiage for exception view + +* Fix styles for message compose template + +* Various improvements to batch worksheets, index links etc. + +* Fix batch links when viewing purchase object + +* Add "on order" count to products grid, tweak product notes panel + + +0.6.13 (2017-07-26) +------------------- + +* Allow master view to decide whether each grid checkbox is checked + + +0.6.12 (2017-07-26) +------------------- + +* Add basic support for product inventory and status + +* Stop allowing pre-0.7 SQLAlchemy + + +0.6.11 (2017-07-18) +------------------- + +* Tweak some basic styles for forms/grids + +* Add new v3 master with v2 forms, with colander/deform + + +0.6.10 (2017-07-18) +------------------- + +* Fix grid bug if "current page" becomes invalid + + +0.6.9 (2017-07-15) +------------------ + +* Expose version history for all supported tables + + +0.6.8 (2017-07-14) +------------------ + +* Provide default renderers for SA mapped tables, where possible + +* Add flexible grid class for v3 grids for width=half etc. + +* Final grid refactor; we now have just 'grids' :) + +* Refactor (coalesce) all batch-related templates + + +0.6.7 (2017-07-14) +------------------ + +* Fix master view ``get_effective_data()`` for v3 grids + + +0.6.6 (2017-07-14) +------------------ + +* Fix bug for printing one-off product labels + + +0.6.5 (2017-07-14) +------------------ + +* Fix template/styles for v3 grid views, add purchasing batch status + + +0.6.4 (2017-07-14) +------------------ + +* Add new "v3" grids, refactor all views to use them + + +0.6.3 (2017-07-13) +------------------ + +* Sort mobile receiving batches by ID desc + +* Add initial/basic support for "simple" mobile grid filter w/ radio buttons + +* Add filter support for mobile row grid; plus mark receiving as complete + +* Disable unused Clear button for mobile receiving + +* Add logic for mobile receiving if product not in batch and/or system + +* Prevent mobile receiving actions for batch which is complete or executed + +* Fix bug with mobile receiving UPC lookup; require stronger "create row" perm + +* Stop using popup for expiration date, for mobile receiving + +* Add global key handler for mobile receiving, for scanner wedge input + +* Make all batches support mobile by default + +* Add basic support for viewing inventory batches on mobile + +* Refactor keypad widget for mobile receiving + +* Add unit cost for inventory batches + + +0.6.2 (2017-07-10) +------------------ + +* Fix CS/EA bug for mobile receiving + + +0.6.1 (2017-07-07) +------------------ + +* Switch license to GPL v3 (no longer Affero) + +* Fix broken product image tag, per webhelpers2 + + +0.6.0 (2017-07-06) +------------------ + +Main reason for bumping version is the (re-)addition of data versioning support +using SQLAlchemy-Continuum. This feature has been a long time coming and while +not yet fully implemented, we have a significant head start. + +* Add custom default grid row size for Trainwreck items + +* Make hyperlink optional for employee field renderer + +* Tweak how customer/person relationships are displayed + +* Add initial support for expiration date for mobile receiving + +* Make Person.employee field readonly + +* Rearrange some imports to ensure ``rattail.db.model`` comes last + +* Add basic versioning history support for master view + +* Remove old-style continuum version views + +* Remove all "old-style" (aka. version 1) grids + +* Remove all old-style views: grids, CRUD, versions etc. + +* Refactor to use webhelpers2 etc. instead of older 'webhelpers' + + +0.5.104 (2017-06-22) +-------------------- + +* Add basic views for Trainwreck transactions + +* Add ``AlchemyLocalDateTimeFilter`` + +* Add row count as available column to batch header grids + +* Try to keep batch status updated; display it for handheld batches + +* Tweak display of inventory/label batches to reflect multiple handheld batches + +* Add way to execute multiple handheld batches (search results) at once + +* Fix batch row count when deleting a row + +* Make case/unit quantities prettier within Inventory batch rows grid + +* Sort (alphabetically) device type list field when making new handheld batch + +* Allow bulk row deletion for vendor catalog batches + + +0.5.103 (2017-06-05) +-------------------- + +* Always add key as class to grid column headers; allow literal label + + +0.5.102 (2017-05-30) +-------------------- + +* Remove all views etc. for old-style batches + +* Fix bug when updating Order Form data, if row.po_total is None + + +0.5.101 (2017-05-25) +-------------------- + +* Fix subtle bug when identifying purchase batch row on order form update + +* Remove references to deprecated batch handler methods + +* Add validation for unique name when creating new Setting + +* Simplify page title display for mobile base template + +* Refactor "purchasing" batch views, split off "ordering" + +* Add initial (full-ish) support for mobile receiving views + +* Add support for bulk-delete of Pricing Batches + +* Pad session timeout warning by 10 seconds, to account for drift + +* Add highlight to active row within Order Form view + +* Make 'notes' field use textarea renderer by default, for all batches + +* Add basic ability to download Ordering Batch as Excel spreadsheet + + +0.5.100 (2017-05-18) +-------------------- + +* Allow batch view to override execution failure message + +* Tweak some customer view/field rendering, to allow more customization + +* Remove customer view template (use master default) + +* Add basic support for Trainwreck database connectivity + +* Remove unused 'fake_error' view + +* Add basic 'robots.txt' support to CommonView + +* Cap our pyramid_tm version until we can upgrade to pyramid 1.9 + +* Add daily hour totals when viewing or editing single employee time sheet + +* Let config cause time sheet hours to display as HH.HH for some users + +* Expose full-time flag and start date for employee view + +* Add convenience ``dialog_button()`` JS function + + +0.5.99 (2017-05-05) +------------------- + +* Add allowance for Escape key, in numeric.js + +* Let a batch disallow bulk-deletion of its rows + +* Add basic support for deletion speedbump for row data + +* Remove lower version for Pyramid dependency, but restrict to pre-1.9 + + +0.5.98 (2017-04-18) +------------------- + +* Auto-save time sheet day editor on Enter press if time field is focused + +* Add simple flag to prevent multiple submits for Order Form AJAX + + +0.5.97 (2017-04-04) +------------------- + +* Fix signature for ``MasterView.get_index_url()`` + + +0.5.96 (2017-04-04) +------------------- + +* Tweak logic for registering exception view, to avoid test breakage + +* Add basic paging grid/index support for mobile + +* Tweak field label styles for mobile + +* Allow config to define home page image URL + + +0.5.95 (2017-03-29) +------------------- + +* Tweak organization panel for product view template + +* Add logic to core View class, to force logout if user becomes inactive + +* Detect "backwards" shift when time sheet is edited, alert user + +* Add default view for unhandled exceptions, configure only for production + +* Add basic table listing view, with rough estimate row counts + +* Add 'status' column to vendor cost table in product view + +* Various template standardization tweaks + + +0.5.94 (2017-03-25) +------------------- + +* Add ``CostFieldRenderer`` and tweak product view template + +* Bump margin between grid and header table, i.e. buttons + +* Broad refactor to improve customization of purchase order form etc. + +* Fix route sequence for people autocomplete + +* Fix bugs when checking for 'chuck' in demo mode + +* Add unit item and pack size fields to product view + + +0.5.93 (2017-03-22) +------------------- + +* Add 'is_any' verb to integer grid filters + +* Add more variations of project name when creating via scaffold + +* Various tweaks to the customer and person views/forms + +* Add basic "mobile index" master view, plus support for demo mode + +* Refactor the batch file field renderer somewhat + +* Move ``notfound()`` method to core ``View`` class + +* Add ``BatchMasterView.add_file_field()`` convenience method + +* Add ``extra_main_fields()`` method to product view template + +* Allow config to override jQuery UI version + +* Add master view for Report Output data model + + +0.5.92 (2017-03-14) +------------------- + +* Tweak grid configuration for Employees view + +* Add trailing '?' for employee time sheet when hours are incomplete + + +0.5.91 (2017-03-03) +------------------- + +* Add 'discontinued' flag to product view + + +0.5.90 (2017-03-01) +------------------- + +* Add notes, ingredients to product view + + +0.5.89 (2017-02-24) +------------------- + +* Expose/honor per-role session timeouts + +* Fix daylight savings bug when cloning schedule from previous week + +* Expose notes field for purchasing batches + +* Add some product flags (kosher vegan etc.) to view fieldset + +* Add initial support for native product images + + +0.5.88 (2017-02-21) +------------------- + +* Fix session reference bug in schedule view + + +0.5.87 (2017-02-21) +------------------- + +* Fix bug in DateFieldRenderer when no format specified + + +0.5.86 (2017-02-21) +------------------- + +* Add initial/basic views for customer orders data + +* Be less aggressive when validating schedule edit form POST + + +0.5.85 (2017-02-19) +------------------- + +* Add generic "bulk delete" support to MasterView + +* Add beginnings of mobile receiving views + + +0.5.84 (2017-02-17) +------------------- + +* Tweak progress template to better handle reset to 0% + +* Add ability to merge 2 user accounts + +* Increase size of Roles select when editing a User + +* Add ability to filter Sent Messages by recipient name + + +0.5.83 (2017-02-16) +------------------- + +* Set form id for new purchasing batch page + +* Make sure invoice number is saved when making new purchasing batch + +* Tweak product view page styles (new grids etc.) + +* Add support for client-side session timeout warning + + +0.5.82 (2017-02-14) +------------------- + +* Collapse grid actions if there are only 2 + +* Add master view for generic exports + +* Make some product fields readonly + +* Make datasync changes viewable + +* Redirect to login page when Forbidden happens with anonymous user + +* Tweak styles for Send Message page + +* Tweak form handling for sending a new message, for more customization + +* Advance to password field when Enter pressed on username, login page + +* Add way for ``login_user()`` to set different timeout depending on nature of login + + +0.5.81 (2017-02-11) +------------------- + +* Add config for redirecting user to home page after logout + +* Refactor logic used to login a user, for easier sharing + +* Use ``pretty_hours()`` function where applicable + + +0.5.80 (2017-02-10) +------------------- + +* Tweak renderer for Amount field for DepositLink view + +* Tweak how regular/current price fields are handled for Product view + +* Fix bug in base 'shifts' template if ``weekdays`` not in context + + +0.5.79 (2017-02-09) +------------------- + +* Tweak product view template per rename of case_size field + +* Refactor the Edit Time Sheet view for "autocommit" mode + +* Don't render user field as hyperlink unless so configured + +* Expose 'delay' field in tempmon client views + +* Fix bug when first entry is empty for product on ordering form + + +0.5.78 (2017-02-08) +------------------- + +* Add initial Find Roles/Users by Permission feature + +* Fix sorting bug for Employee Time Sheet view + + +0.5.77 (2017-02-04) +------------------- + +* Invoke timepicker to correct format of user input, for edit schedule/timesheet + + +0.5.76 (2017-02-04) +------------------- + +* Add hyperlink to ``EmployeeFieldRenderer`` + +* Improve the grid for ``WorkedShift`` model a bit + +* Add config flag for disabling option to "Clear Schedule" + + +0.5.75 (2017-02-03) +------------------- + +* Fix probe filter for tempmon readings grid + +* Be explicit about fieldset for pricing batch rows + +* Let project override user authentication for login page + +* Add basic support for per-user session timeout + + +0.5.74 (2017-01-31) +------------------- + +* Refactor schedule / timesheet views for better separation of concerns + + +0.5.73 (2017-01-30) +------------------- + +* Add pyramid_mako dependency, remove minimum version for rattail + +* Add ability to edit employee time sheet + +* Add 'target' kwarg for grid action links + +* Add hyperlink to User field renderer + +* Add min diff threshold param when making price batch from product query + +* Add way for batch views to hide rows with given status code(s) + + +0.5.72 (2017-01-29) +------------------- + +* Add basic support for cloning batches + +* Tweaks to order form template etc., for purchasing batch + +* Let master view with rows prevent sort/filter for row grid + +* Add price diff column to pricing batch row grid + +* Add warning highlight for pricing batch row if can't calculate price + + +0.5.71 (2017-01-24) +------------------- + +* Improve columns, filters for TempMon Readings grid + +* Add ability to merge subdepartments + + +0.5.70 (2017-01-11) +------------------- + +* Fix CSRF token bug with email preview form, refactor to use webhelpers + + +0.5.69 (2017-01-06) +------------------- + +* When making batch from products, build query *before* starting thread + + +0.5.68 (2017-01-03) +------------------- + +* Prefer received quantities over ordered quantities, for Order Form history + + +0.5.67 (2017-01-03) +------------------- + +* Add department UUID to JSON returned for "eligible purchases" when creating batch + +* Set "order date" when creating new receiving batch + +* Add "discarded" flag when receiving DMG/EXP products; add view for purchase credits + +* Fix type error in grid numeric filter + + +0.5.66 (2016-12-30) +------------------- + +* Tweak the "create" screen for purchase batches, for more customization + + +0.5.65 (2016-12-29) +------------------- + +* Fix purchase batch execution, to redirect to Purchase *or* Batch + +* Add extra perms for restricing which 'mode' of purchase batch user can create + +* Refactor Order Form a bit to allow custom history data + + +0.5.64 (2016-12-28) +------------------- + +* Tweak default "numeric" grid filter, to ignore UPC-like values + +* Tweak default filter label for Batch ID + + +0.5.63 (2016-12-28) +------------------- + +* Fix CSRF token bug for bulk-move message forms + + +0.5.62 (2016-12-22) +------------------- + +* Fix CSRF token bug for old-style batch params form + + +0.5.61 (2016-12-21) +------------------- + +* Fix master merge template/forms to include CSRF token + + +0.5.60 (2016-12-20) +------------------- + +* Fix CSRF bug in Ordering Form template, make case quantity pretty + +* Fix some bugs in product view template + +* Update some enum references, render all purchase/batch cases/units fields as quantity + + +0.5.59 (2016-12-19) +------------------- + +* Add ``QuantityFieldRenderer`` + +* Add style for 'half-width' grid + + +0.5.58 (2016-12-16) +------------------- + +* Add ``ValidGPC`` formencode validator + +* Overhaul the Receiving Form to account for "product not found" etc. + +* Auto-append slash to URL when necessary + +* Add "print receiving worksheet" feature, for 'ordered' purchases + +* Add global CSRF protection + +* Tweak some field renderers + +* Overhaul product views a little, per customization needs + + +0.5.57 (2016-12-12) +------------------- + +* Lots of changes for sake of mobile login / user menu etc. + +* Add mobile support for datasync restart + +* Make ``CurrencyFieldRenderer`` inherit from ``FloatFieldRenderer`` + +* Fix session bug in old CRUD views + + +0.5.56 (2016-12-11) +------------------- + +* Show 'enabled' column in grid, fix prefix bug for email profiles + +* Tweak flash message when sending email preview, in case it's disabled + +* Hide first/last name for employee view, unless in readonly mode + +* Add initial mobile templates: base, home, about + + +0.5.55 (2016-12-10) +------------------- + +* Validate for unique tempmon probe config key + +* Add 'restartable tempmon client' conditional logic + + +0.5.54 (2016-12-10) +------------------- + +* Add new 'receiving form' for purchase batches + +* Add support for 'department' field in purchases / batches + +* Add generic 'not on file' product image for use as POD 404 + +* Add logic for handling Ctrl+V / Ctrl+X in numeric.js + + +0.5.53 (2016-12-09) +------------------- + +* Fix bug when editing a data row + + +0.5.52 (2016-12-08) +------------------- + +* Fix permission group label for email bounces + +* Update footer text/link per new about page + + +0.5.51 (2016-12-07) +------------------- + +* Fix permission / grid action bug for email profiles + + +0.5.50 (2016-12-07) +------------------- + +* Tweak tempmon views a little, fix client restart logic + +* Add 'extra_styles' to true base template + +* Add new "bytestring" filter for grids that need it + + +0.5.49 (2016-12-05) +------------------- + +* Allow delete for datasync changes + +* Fix import bugs with tempmon views + +* Use master view's session when creating form + + +0.5.48 (2016-12-05) +------------------- + +* Tweak email config views, to support subject "templates" + +* Refactor tempmon views to leverage rattail-tempmon database + + +0.5.47 (2016-11-30) +------------------- + +* Fix bug in products view class + + +0.5.46 (2016-11-29) +------------------- + +* Add basic 'about' page with some package versions + +* Tweak fields for product view + + +0.5.45 (2016-11-28) +------------------- + +* Fix styles for 'print schedule' page + +* Add permission for bulk-delete of batch data rows + + +0.5.44 (2016-11-22) +------------------- + +* Add some links between employees / people / customers views + +* Add support for pricing batches + +* Add initial views for tempmon clients/probes/readings + + +0.5.43 (2016-11-21) +------------------- + +* Add support for receive/cost mode, purchase relation for purchase batches + +* Bump jquery version + +* Fix bug when downloading batch file + + +0.5.42 (2016-11-20) +------------------- + +* Move ``get_batch_kwargs()`` to ``BatchMasterView`` + + +0.5.41 (2016-11-20) +------------------- + +* Add printer-friendly view for "full" employee schedule + +* Fix some bugs etc. with batch views and templates + + +0.5.40 (2016-11-19) +------------------- + +* Add size, extra link fields to product view template + +* Refactor batch views / templates per rattail framework overhaul + + +0.5.39 (2016-11-14) +------------------- + +* Make POD image for product view a bit more sane + +* Disable save button when creating new object + + +0.5.38 (2016-11-11) +------------------- + +* Tweak default factory for boolean grid filters + +* Add support for more cases + units, more vendor fields, for new purchase batches + + +0.5.37 (2016-11-10) +------------------- + +* Display sequence for product alt codes + +* Change how we determine default 'grid key' for master views + +* Add 'additive fields' concept to merge diff preview + + +0.5.36 (2016-11-09) +------------------- + +* Add historical amounts to new purchase Order Form, allow extra columns etc. + +* Tweak verbiage for merge template etc. + + +0.5.35 (2016-11-08) +------------------- + +* Add support for new Purchase/Batch views, 'create row' master pattern + +* Add basic views for label batches + +* Add support for making new-style batches from products grid query + +* Add initial support for viewing new purchase batch as Order Form + +* Refactor how batch editing is done; don't include rows for that sometimes + + +0.5.34 (2016-11-02) +------------------- + +* Add basic merge feature to ``MasterView`` + + +0.5.33 (2016-10-27) +------------------- + +* Fix template bug when deleting user + +* Tweak default styles for home page + +* Show vendor invoice rows as warning, if they have no case quantity + +* Add 'vendor code' and 'vendor code (any)' filters for products grid + +* Fix bug with how we auto-filter 'deleted' products (?) + + +0.5.32 (2016-10-19) +------------------- + +* Fix / improve progress display somewhat + +* Disable "true delete" button by default, when clicked + +* Fix bug in batch ID field renderer, when displayed for new batch + +* Add ``refresh_after_create`` flag for ``BatchMasterView`` + +* Disable a focus() call in menubar.js which messed with search filter focus + +* Let any 'admin' user elevate to 'root' for full system access + +* Update references to ``request.authenticated_userid`` + + +0.5.31 (2016-10-14) +------------------- + +* Add ability to edit employee schedule + + +0.5.30 (2016-10-10) +------------------- + +* Tweak some things to make demo project more "out of the box" + +* Add registration for 'rattail' template with Pyramid scaffold system + +* Add 'tailbone' to global template context, update 'better' template footer + +* Tweak how tailbone finds rattail config from pyramid settings + +* Remove last references to 'edbob' package + +* Strip whitespace from username field when editing User + +* Fix couple of bugs for vendor catalog views + +* Add size description to inventory report + + +0.5.29 (2016-10-04) +------------------- + +* Add ``code`` field to Category views + +* Add "bulk delete rows" feature to new batches view + + +0.5.28 (2016-09-30) +------------------- + +* Add specific permissions for edit/delete of individual batch rows + + +0.5.27 (2016-09-26) +------------------- + +* Add basic form validation when sending new messages + +* Add "just in time" editable instance check for master view + +* Add "refresh" button when viewing batch + +* Add FormAlchemy-compatible validators for email address, phone number + +* Improve validation for FormAlchemy date field renderer + +* Fix row-level visibility for grid edit action + +* Add a couple of extra verbs to base grid filter class + +* Tweak how a grid filter factory is determined + + +0.5.26 (2016-09-01) +------------------- + +* Add ``MasterView.listable`` flag for disabling grid view + +* Fix permission group label bug for batch views + +* Allow opt-out for "download batch row data as CSV" feature + + +0.5.25 (2016-08-23) +------------------- + +* Tweak how we use DB session to fetch grid settings + +* Add "sub-rows" support to MasterView class + +* Refactor batch views to leverage MasterView sub-rows logic + +* Refactor batch view/edit pages to share some "execution options" logic + +* Add hook to customize timesheet shift rendering + + +0.5.24 (2016-08-17) +------------------- + +* Fix bug in handheld batch view config + + +0.5.23 (2016-08-17) +------------------- + +* Fix bug when viewing batch with no execution options + + +0.5.22 (2016-08-17) +------------------- + +* Fix bug for handheld batch device type field + + +0.5.21 (2016-08-17) +------------------- + +* Add ``MasterView.render()`` method for sake of common context/logic + +* Add "empty" option to enum field renderers, if field allows empty value + +* Add support for system-unique ID in batch views etc. + +* Fix bug when deleting certain batches + +* Fix bug in batch download URL + +* Add basic support for batch execution options + +* Add basic support for new handheld/inventory batches + + +0.5.20 (2016-08-13) +------------------- + +* Add null / not null verbs back to default boolean grid filter + + +0.5.19 (2016-08-12) +------------------- + +* Only show granted permissions when viewing role details + +* Expose 'enabled' flag for email profile/settings + +* Add permissions field when viewing user details + + +0.5.18 (2016-08-10) +------------------- + +* Add ``render_progress()`` method to core view class + +* Add hopefully generic ``FileFieldRenderer`` + + +0.5.17 (2016-08-09) +------------------- + +* Add support for 10-key hyphen/period keys for numeric input fields + + +0.5.16 (2016-08-05) +------------------- + +* Fallback to empty string for email preview recipient, if current user has no address + +* Allow negative sign, decimal point for "numeric" text fields + + +0.5.15 (2016-07-27) +------------------- + +* Add initial attempt at 'better' theme + +* Add ``CodeTextAreaFieldRenderer``, refactor label profile form to use it + + +0.5.14 (2016-07-08) +------------------- + +* Allow extra kwargs to core ``View.redirect()`` method + +* Add awareness of special 'Authenticated' role, in permissions UI etc. + +* Always strip whitespace from label profile 'spec' field input + + +0.5.13 (2016-06-10) +------------------- + +* Hopefully fix some CSS for form field values + +* Add support for viewing single employee's schedule / time sheet + + +0.5.12 (2016-05-11) +------------------- + +* Add support for "full" schedule and time sheet views. + +* Move "full name" to front of Person grid columns. + +* Add rattail config object to ``Session`` kwargs. + + +0.5.11 (2016-05-06) +------------------- + +* Refactor some common FormEncode validators, plus add some more. + +* Tweak styles for jQuery UI selectmenu dropdowns. + +* Tweak timesheet styles, to give rows alternating background color. + +* Disable autocomplete for password fields when editing user. + +* Various incomplete improvements to the timesheet/schedule views. + + +0.5.10 (2016-05-05) +------------------- + +* Refactor timesheet logic, add basic schedule view. + +* Add prev/next/jump week navigation to time sheet, schedule views. + +* Add hyperlinks to product UPC and description, within main grid. + +* Fix bug in roles view. + + +0.5.9 (2016-05-02) +------------------ + +* Remove 'create batch from results' link on products index page. + +* Fix bugs in batch grid URLs. + +* Tweak how empty hours are displayed in time sheet. + + +0.5.8 (2016-05-02) +------------------ + +* Add ``MasterView.listing`` flag, for templates' sake. + +* Overhaul newgrid template header a bit, to improve styles. + +* Move ``Person.display_name`` to top of fieldset when viewing/editing. + +* Add 'testing' image, for background / watermark. + +* Add 'index title' setting to master view. + +* Add auto-hide/show magic to message recipients field when viewing. + +* Add initial support for grid index URLs. + +* Add initial/basic user feedback form support. + +* Stop trying to use PIL when generating product image tag. + + +0.5.7 (2016-04-28) +------------------ + +* Add master views for ``ScheduledShift`` model. + +* Add initial (incomplete) Time Sheet view. + + +0.5.6 (2016-04-25) +------------------ + +* Add views for ``WorkedShift`` model. + + +0.5.5 (2016-04-24) +------------------ + +* Add workarounds for certain display bugs when rendering datetimes. + +* Make currency field renderer display negative amounts in parentheses. + +* Add commas to record/page count in grid footer. + +* Tweak styles for form field labels. + + +0.5.4 (2016-04-12) +------------------ + +* Add support for column header title (tooltip) in new grids. + +* Change default filter type for integer fields, in new grids. + +* Add flag for rendering key value, for enum field renderers. + +* Fix case-sensitivity when sorting permission group labels. + + +0.5.3 (2016-04-05) +------------------ + +* Fix redirect bug when attempting bulk row delete for nonexistent batch. + +* Add comma magic back to ``CurrencyFieldRenderer``. + +* Add the 'is any' verb to default list for most grid filters. + +* Add new ``TimeFieldRenderer``, make it default for ``Time`` fields. + +* Add last-minute check to ensure master views allows deletion. + + +0.5.2 (2016-03-11) +------------------ + +* Make ``tailbone.views.labels`` a subpackage instead of module. + +* Add 'executed' to old batches grid view. + +* Make all timestamps show "raw" by default (with "diff" tooltip). + +* Improve grid filters for datetime fields (smarter verbs). + +* Fix bug where batch creator was being set to current user anytime it was viewed..yikes. + + +0.5.1 (2016-02-27) +------------------ + +* Fix bug when rendering email bounce links. + + +0.5.0 (2016-02-15) +------------------ + +* Refactor products view(s) per new master pattern. + +* Make our ``DateTimeFieldRenderer`` the default for datetime fields. + +* Add new ``BatchMasterView`` for new-style batches. + +* Overhaul vendor catalogs, vendor invoices views to use new batch master class. + +* Refactor some more model views to use MasterView. (depositlink, tax, emailbounce) + +* Make datasync views easier to customize. + + +0.4.42 +------ + +* Add initial reply / reply-all support for messages. + +* Add subscriber hook for setting inbox count in template context. + + +0.4.41 +------ + +* Tweak how we connect a user to a batch, when refreshing. + +* Add 'Move' button to message view template. + + +0.4.40 +------ + +* Make rattail config object use our scoped session, when consulting db. + + +0.4.39 +------ + +* Add support for sending new messages. + + +0.4.38 +------ + +* Add 'password is/not null' filter to users list view. + +* Remove style hack for message grid views. + + +0.4.37 +------ + +* Add 'messages.list' permission, to protect inbox etc. + + +0.4.36 +------ + +* Fix bug when marking batch as executed. + + +0.4.35 +------ + +* Change default form buttons so Cancel is also a button. + +* Add 'Stores' and 'Departments' fields to Employee fieldset. + + +0.4.34 +------ + +* Add 'restart datasync' button to datasync changes list page. + +* Add autocomplete vendor field renderer. + +* Change vendor catalog upload, to allow vendor-less parsers. + +* Stop depending on PIL...for now? + + +0.4.33 +------ + +* Add employee/department relationships to employee and department views. + + +0.4.32 +------ + +* Add edit mode for email "profile" settings. + +* Fix auto-creation of grid sorter, when joined table is involved. + +* Add initial support for 'messages' views. + + +0.4.31 +------ + +* Add speed bump / confirmation page when deleting records. + +* Add "grid tools" to "complete" grid template. + +* Add ``Person.middle_name`` to the fieldset. + + +0.4.30 +------ + +* Add config extension, to record data changes if so configured. + +* Add mailing address to person fieldset. + + +0.4.29 +------ + +* Fix some route names. + + +0.4.28 +------ + +* Use sample data when generating subject for display in email profile settings. + +* Convert (most?) basic views to use master view pattern. + + +0.4.27 +------ + +* Change default sortkey for email profiles list. + +* Add 'To' field to email profile settings grid. + + +0.4.26 +------ + +* Add readonly support for email profile settings. + + +0.4.25 +------ + +* Fix bug when 'edbob.permissions' setting is empty. + +* Tweak some things to get Tailbone working on its own. + +* Let subclass of MasterView override the database Session it uses. + + +0.4.24 +------ + +* Render ``DataSyncChange.obtained`` as humanized timestamp within UI. + + +0.4.23 +------ + +* Delete product costs for vendor when deleting vendor. + +* Work around formalchemy config bug, caused by edbob. + +* Add view to show DataSync changes, for basic troubleshooting. + + +0.4.22 +------ + +* Remove format hack which isn't py2.6-friendly. + + +0.4.21 +------ + +* Add "valueless verbs" concept to grid filters. + +* Tweak labels for new grid filter form buttons. + +* Configure logging when starting up. + +* Add HTML5 doctype to base template. + +* More grid filter improvements; add choice/enum/date value renderers. + +* Treat filter by "contains X Y" as "contains X and contains Y". + +* Tweak layout CSS so page body expands to fill screen. + + +0.4.20 +------ + +* Add ``CurrencyFieldRenderer``. + +* Add basic checkbox support to new grids. + +* Add 'Default Filters' and 'Clear Filters' buttons to new grid filters form. + +* Add "Save Defaults" button so user can save personal defaults for any new grid. + +* Fix bug when rendering hidden field in FA fieldset. + +* Remove some unused styles. + +* Various tweaks to support "late login" idea when uploading new batch. + +* Hard-code old grid pagecount settings, to avoid ``edbob.config``. + +* Refactor app configuration to use ``rattail.config.make_config()``. + +* Tweak label formatter instantiation, per rattail changes. + +* Various tweaks to base batch views. + +* Add ``CustomFieldRenderer`` and ``DateFieldRenderer``. + +* Add ``configure_fieldset()`` stub for master view. + +* Add progress indicator to batch execution. + +* Add ability to download batch row data as CSV. + + +0.4.19 +------ + +* Fix progress template, per jQuery CDN changes. + + +0.4.18 +------ + +* Don't show flash message when user logs in. + +* Add core JS/CSS to base template; use CDN instead of cached files. + +* Add support for "new-style grids" and "model master views", and convert the + following views to use it: roles, users, label profiles, settings. Also + overhaul how permissions are registered in app config. + + +0.4.17 +------ + +* Log warning instead of error when refreshing batch fails. + + +0.4.16 +------ + +* Add initial support for email bounce management. + + +0.4.15 +------ + +* Fix missing import bug. + + +0.4.14 +------ + +* Make anchor tags with 'button' class render as jQuery UI buttons. + +* Tweak ``app.make_rattail_config()`` to allow caller to define some settings. + +* Add ``display_name`` field to employee CRUD view. + +* Allow batch handler to disable the Execute button. + +* Add ``StoreFieldRenderer`` and ``DecimalFieldRenderer``. + +* Tweak how default filter config is handled for batch grid views. + +* Add list of assigned users to role view page. + +* Add products autocomplete view. + +* Add ``rattail_config`` attribute to base ``View`` class. + +* Fix timezone issues with ``util.pretty_datetime()`` function. + +* Add some custom FormEncode validators. + + +0.4.13 +------ + +* Fix query bugs for batch row grid views (add join support). + +* Make vendor field renderer show ID in readonly mode. + +* Change permission requirement for refreshing a batch's data. + +* Add flash message when any batch executes successfully. + +* Add autocomplete view for current employees. + +* Add autocomplete employee field renderer. + +* Fix usage of ``Product.unit_of_measure`` vs. ``Product.weighed``. + + +0.4.12 +------ + +* Fix bug when creating batch from product query. + + +0.4.11 +------ + +* Tweak old-style batch execution call. + + +0.4.10 +------ + +* Add 'fake_error' view to test exception handling. + +* Add ability to view details (i.e. all fields) of a batch row. + +* Fix bulk delete of batch rows, to set 'removed' flag instead. + +* Fix vendor invoice validation bug. + +* Add dept. number and friends to product details page. + +* Add "extra panels" customization hook to product details template. + + +0.4.9 +----- + +* Hide "print labels" column on products list view if so configured. + + +0.4.8 +----- + +* Fix permission for deposit link list/search view. + +* Fix permission for taxes list/search view. + + +0.4.7 +----- + +* Add views for deposit links, taxes; update product view. + +* Add some new vendor and product fields. + +* Add panels to product details view, etc. + +* Fix login so user is sent to their target page after authentication. + +* Don't allow edit of vendor and effective date in catalog batches. + +* Add shared GPC search filter, use it for product batch rows. + +* Add default ``Grid.iter_rows()`` implementation. + +* Add "save" icon and grid column style. + +* Add ``numeric.js`` script for numeric-only text inputs. + +* Add product UPC to JSON output of 'products.search' view. + + +0.4.6 +----- + +* Add vendor catalog batch importer. + +* Add vendor invoice batch importer. + +* Improve data file handling for file batches. + +* Add download feature for file batches. + +* Add better error handling when batch refresh fails, etc. + +* Add some docs for new batch system. + +* Refactor ``app`` module to promote code sharing. + +* Force grid table background to white. + +* Exclude 'deleted' items from reports. + +* Hide deleted field from product details, according to permissions. + +* Fix embedded grid URL query string bug. + + +0.4.5 +----- + +* Add prettier UPCs to ordering worksheet report. + +* Add case pack field to product CRUD form. + + +0.4.4 +----- + +* Add UI support for ``Product.deleted`` column. + + +0.4.3 +----- + +* More versioning support fixes, to allow on or off. + + +0.4.2 +----- + +* Rework versioning support to allow it to be on or off. + + +0.4.1 +----- + +* Only attempt to count versions for versioned models (CRUD views). + + +0.4.0 +----- + +This version primarily got the bump it did because of the addition of support +for SQLAlchemy-Continuum versioning. There were several other minor changes as +well. + +* Add department to field lists for category views. + +* Change default sort for People grid view. + +* Add category to product CRUD view. + +* Add initial versioning support with SQLAlchemy-Continuum. + + +0.3.28 +------ + +* Add unique username check when creating users. + +* Improve UPC search for rows within batches. + +* New batch system... + + +0.3.27 +------ + +* Fix bug with default search filters for SA grids. + +* Fix bug in product search UPC filter. + +* Ugh, add unwanted jQuery libs to progress template. + +* Add support for integer search filters. + + +0.3.26 +------ + +* Use boolean search filter for batch column filters of 'FLAG' type. + + +0.3.25 +------ + +* Make product UPC search view strip non-digit chars from input. + + +0.3.24 +------ + +* Make ``GPCFieldRenderer`` display check digit separate from main barcode + data. + +* Add ``DateTimeFieldRenderer`` to show human-friendly timestamps. + +* Tweak CRUD form buttons a little. + +* Add grid, CRUD views for ``Setting`` model. + +* Update ``base.css`` with various things from other projects. + +* Fix bug with progress template, when error occurs. + + +0.3.23 +------ + +* Fix bugs when configuring database session within threads. + + +0.3.22 +------ + +* Make ``Store.database_key`` field editable. + +* Add explicit session config within batch threads. + +* Remove cap on installed Pyramid version. + +* Change session progress API. + + +0.3.21 +------ + +* Add monospace font for label printer format command. + + +0.3.20 +------ + +* Refactor some label printing stuff, per rattail changes. + + +0.3.19 +------ + +* Add support for ``Product.not_for_sale`` flag. + + +0.3.18 +------ + +* Add explicit file encoding to all Mako templates. + +* Add "active" filter to users view; enable it by default. + + +0.3.17 +------ + +* Add customer phone autocomplete and customer "info" AJAX view. + +* Allow editing ``User.active`` field. + +* Add Person autocomplete view which restricts to employees only. + + +0.3.16 +------ + +* Add product report codes to the UI. + + +0.3.15 +------ + +* Add experimental soundex filter support to the Customers grid. + + +0.3.14 +------ + +* Add event hook for attaching Rattail ``config`` to new requests. + +* Fix vendor filter/sort issues in products grid. + +* Add ``Family`` and ``Product.family`` to the general grid/crud UI. + +* Add POD image support to product view page. + + +0.3.13 +------ + +* Use global ``Session`` from rattail (again). + +* Apply zope transaction to global Tailbone Session class. + + +0.3.12 +------ + +* Fix customer lookup bug in customer detail view. + +* Add ``SessionProgress`` class, and ``progress`` views. + + +0.3.11 +------ + +* Removed reliance on global ``rattail.db.Session`` class. + + +0.3.10 +------ + +* Changed ``UserFieldRenderer`` to leverage ``User.display_name``. + +* Refactored model imports, etc. + + This is in preparation for using database models only from ``rattail`` + (i.e. no ``edbob``). Mostly the model and enum imports were affected. + +* Removed references to ``edbob.enum``. + + +0.3.9 +----- + +* Added forbidden view. + +* Fixed bug with ``request.has_any_perm()``. + +* Made ``SortableAlchemyGridView`` default to full (100%) width. + +* Refactored ``AutocompleteFieldRenderer``. + + Also improved some organization of renderers. + +* Allow overriding form class/factory for CRUD views. + +* Made ``EnumFieldRenderer`` a proper class. + +* Don't sort values in ``EnumFieldRenderer``. + + The dictionaries used to supply enumeration values should be ``OrderedDict`` + instances if sorting is needed. + +* Added ``Product.family`` to CRUD view. + + +0.3.8 +----- + +* Fixed manifest (whoops). + + +0.3.7 +----- + +* Added some autocomplete Javascript magic. + + Not sure how this got missed the first time around. + +* Added ``products.search`` route/view. + + This is for simple AJAX uses. + +* Fixed grid join map bug. + + +0.3.6 +----- + +* Fixed change password template/form. + + +0.3.5 +----- + +* Added ``forms.alchemy`` module and changed CRUD view to use it. + +* Added progress template. + + +0.3.4 +----- + +* Changed vendor filter in product search to find "any vendor". + + I.e. the current filter is *not* restricted to the preferred vendor only. + Probably should still add one (back) for preferred only as well; hence the + commented code. + + +0.3.3 +----- + +* Major overhaul for standalone operation. + + This removes some of the ``edbob`` reliance, as well as borrowing some + templates and styling etc. from Dtail. + + Stop using ``edbob.db.engine``, stop using all edbob templates, etc. + +* Fix authorization policy bug. + + This was really an edge case, but in any event the problem would occur when a + user was logged in, and then that user account was deleted. + +* Added ``global_title()`` to base template. + +* Made logo more easily customizable in login template. + + +0.3.2 +----- + +* Rebranded to Tailbone. + + +0.3.1 +----- + +* Added some tests. + +* Added ``helpers`` module. + + Also added a Pyramid subscriber hook to add the module to the template + renderer context with a key of ``h``. This is nothing really new, but it + overrides the helper provided by ``edbob``, and adds a ``pretty_date()`` + function (which maybe isn't a good idea anyway..?). + +* Added ``simpleform`` wildcard import to ``forms`` module. + +* Added autocomplete view and template. + +* Fixed customer group deletion. + + Now any customer associations are dropped first, to avoid database integrity + errors. + +* Stole grids and grid-based views from ``edbob``. + +* Removed several references to ``edbob``. + +* Replaced ``Grid.clickable`` with ``.viewable``. + + Clickable grid rows seemed to be more irritating than useful. Now a view + icon is shown instead. + +* Added style for grid checkbox cells. + +* Fixed FormAlchemy table rendering when underlying session is not primary. + + This was needed for a grid based on a LOC SMS session. + +* Added grid sort arrow images. + +* Improved query modification logic in alchemy grid views. + +* Overhauled report views to allow easier template customization. + +* Improved product UPC search so check digit is optional. + +* Fixed import issue with ``views.reports`` module. + + +0.3a23 +------ + +* Fixed bugs where edit links were appearing for unprivileged users. + +* Added support for product codes. + + These are shown when viewing a product, and may be used to locate a product + via search filters. + + +0.3a22 +------ + +* Removed ``setup.cfg`` file. + +* Added ``Session`` to ``rattail.pyramid`` namespace. + +* Added Email Address field to Vendor CRUD views. + +* Added extra key lookups for customer and product routes. + + Now the CRUD routes for these objects can leverage UUIDs of various related + objects in addition to the primary object. More should be done with this, + but at least we have a start. + +* Replaced ``forms`` module with subpackage; added some initial goodies (many + of which are currently just imports from ``edbob``). + +* Added/edited various CRUD templates for consistency. + +* Modified several view modules so their Pyramid configuration is more + "extensible." This just means routes and views are defined as two separate + steps, so that derived applications may inherit the route definitions if they + so choose. + +* Added Employee CRUD views; added Email Address field to index view. + +* Updated ``people`` view module so it no longer derives from that of + ``edbob``. + +* Added support for, and some implementations of, extra key lookup abilities to + CRUD views. This allows URLs to use a "natural" key (e.g. Customer ID + instead of UUID), for cases where that is more helpful. + +* Product CRUD now uses autocomplete for Brand field. Also, price fields no + longer appear within an editable fieldset. + +* Within Store index view, default sort is now ID instead of Name. + +* Added Contact and Phone Number fields to Vendor CRUD views; added Contact and + Email Address fields to index view. + + +0.3a21 +------ + +- [feature] Added CRUD view and template. + +- [feature] Added ``AutocompleteView``. + +- [feature] Added Person autocomplete view and User CRUD views. + +- [feature] Added ``id`` and ``status`` fields to Employee grid view. + + +0.3a20 +------ + +- [feature] Sorted the Ordering Worksheet by product brand, description. + +0.3a19 +------ + +- [feature] Made batch creation and execution threads aware of + `sys.excepthook`. Updated both instances to use `rattail.threads.Thread` + instead of `threading.Thread`. This way if an exception occurs within the + thread, the registered handler will be invoked. + +0.3a18 +------ + +- [bug] Label profile editing now uses stripping field renderer to avoid + problems with leading/trailing whitespace. + +- [feature] Added Inventory Worksheet report. + +0.3a17 +------ + +- [feature] Added Brand and Size fields to the Ordering Worksheet. Also + tweaked the template styles slightly, and added the ability to override the + template via config. + +- [feature] Added "preferred only" option to Ordering Worksheet. + +0.3a16 +------ + +- [bug] Fixed bug where requesting deletion of non-existent batch row was + redirecting to a non-existent route. + +0.3a15 +------ + +- [bug] Fixed batch grid and CRUD views so that the execution time shows a + pretty (and local) display instead of 24-hour UTC time. + +0.3a14 +------ + +- [feature] Added some more CRUD. Mostly this was for departments, + subdepartments, brands and products. This was rather ad-hoc and still is + probably far from complete. + +- [general] Changed main batch route. + +- [bug] Fixed label profile templates so they properly handle a missing or + invalid printer spec. + +0.3a13 +------ + +- [bug] Fixed bug which prevented UPC search from working on products screen. + +0.3a12 +------ + +- [general] Fixed namespace packages, per ``setuptools`` documentation. + +- [feature] Added support for ``LabelProfile.visible``. This field may now be + edited, and it is honored when displaying the list of available profiles to + be used for printing from the products page. + +- [bug] Fixed bug where non-numeric data entered in the UPC search field on the + products page was raising an error. + +0.3a11 +------ + +- [bug] Fixed product label printing to handle any uncaught exception, and + report the error message to the end user. + +0.3a10 +------ + +- [general] Updated category views and templates. These were sorely out of + date. + +0.3a9 +----- + +- Add brands autocomplete view. + +- Add departments autocomplete view. + +- Add ID filter to vendors grid. + +0.3a8 +----- + +- Tweak batch progress indicators. + +- Add "Executed" column, filter to batch grid. + +0.3a7 +----- + +- Add ability to restrict batch providers via config. + +0.3a6 +----- + +- Add Vendor CRUD. + +- Add Brand views. + +0.3a5 +----- + +- Added support for GPC data type. + +- Added eager import of ``rattail.sil`` in ``before_render`` hook. + +- Removed ``rattail.pyramid.util`` module. + +- Added initial batch support: views, templates, creation from Product grid. + +- Added support for ``rattail.LabelProfile`` class. + +- Improved Product grid to include filter/sort on Vendor. + +- Cleaned up dependencies. + +- Added ``rattail.pyramid.includeme()``. + +- Added ``CustomerGroup`` CRUD view (read only). + +- Added hot links to ``Customer`` CRUD view. + +- Added ``Store`` index, CRUD views. + +- Updated ``rattail.pyramid.views.includeme()``. + +- Added ``email_preference`` to ``Customer`` CRUD. + +0.3a4 +----- + +- Update grid and CRUD views per changes in ``edbob``. + +0.3a3 +----- + +- Add price field renderers. + +- Add/tweak lots of views for database models. + +- Add label printing to product list view. + +- Add (some of) ``Product`` CRUD. + +0.3a2 +----- + +- Refactor category views. + +0.3a1 +----- + +- Initial port to Rattail v0.3. diff --git a/docs/_static/.dummy b/docs/_static/.dummy new file mode 100644 index 00000000..e69de29b diff --git a/docs/api/api/batch/core.rst b/docs/api/api/batch/core.rst new file mode 100644 index 00000000..48d34315 --- /dev/null +++ b/docs/api/api/batch/core.rst @@ -0,0 +1,15 @@ + +``tailbone.api.batch.core`` +=========================== + +.. automodule:: tailbone.api.batch.core + +.. autoclass:: APIBatchMixin + +.. autoclass:: APIBatchView + +.. autoclass:: APIBatchRowView + + .. autoattribute:: editable + + .. autoattribute:: supports_quick_entry diff --git a/docs/api/api/batch/ordering.rst b/docs/api/api/batch/ordering.rst new file mode 100644 index 00000000..4b07e1f2 --- /dev/null +++ b/docs/api/api/batch/ordering.rst @@ -0,0 +1,41 @@ + +``tailbone.api.batch.ordering`` +=============================== + +.. automodule:: tailbone.api.batch.ordering + +.. autoclass:: OrderingBatchViews + + .. autoattribute:: collection_url_prefix + + .. autoattribute:: object_url_prefix + + .. autoattribute:: model_class + + .. autoattribute:: route_prefix + + .. autoattribute:: permission_prefix + + .. autoattribute:: default_handler_spec + + .. automethod:: base_query + + .. automethod:: create_object + +.. autoclass:: OrderingBatchRowViews + + .. autoattribute:: collection_url_prefix + + .. autoattribute:: object_url_prefix + + .. autoattribute:: model_class + + .. autoattribute:: route_prefix + + .. autoattribute:: permission_prefix + + .. autoattribute:: default_handler_spec + + .. autoattribute:: supports_quick_entry + + .. automethod:: update_object diff --git a/docs/api/db.rst b/docs/api/db.rst new file mode 100644 index 00000000..ace21b68 --- /dev/null +++ b/docs/api/db.rst @@ -0,0 +1,6 @@ + +``tailbone.db`` +=============== + +.. automodule:: tailbone.db + :members: diff --git a/docs/api/diffs.rst b/docs/api/diffs.rst new file mode 100644 index 00000000..fb1bba71 --- /dev/null +++ b/docs/api/diffs.rst @@ -0,0 +1,6 @@ + +``tailbone.diffs`` +================== + +.. automodule:: tailbone.diffs + :members: diff --git a/docs/api/forms.rst b/docs/api/forms.rst new file mode 100644 index 00000000..bdeb5cf6 --- /dev/null +++ b/docs/api/forms.rst @@ -0,0 +1,9 @@ + +``tailbone.forms`` +================== + +.. automodule:: tailbone.forms + :members: + +.. autoclass:: tailbone.forms.Form + :members: diff --git a/docs/api/forms.widgets.rst b/docs/api/forms.widgets.rst new file mode 100644 index 00000000..33316903 --- /dev/null +++ b/docs/api/forms.widgets.rst @@ -0,0 +1,6 @@ + +``tailbone.forms.widgets`` +========================== + +.. automodule:: tailbone.forms.widgets + :members: diff --git a/docs/api/grids.core.rst b/docs/api/grids.core.rst new file mode 100644 index 00000000..60155cb2 --- /dev/null +++ b/docs/api/grids.core.rst @@ -0,0 +1,6 @@ + +``tailbone.grids.core`` +======================= + +.. automodule:: tailbone.grids.core + :members: diff --git a/docs/api/grids.rst b/docs/api/grids.rst new file mode 100644 index 00000000..3799cbc8 --- /dev/null +++ b/docs/api/grids.rst @@ -0,0 +1,6 @@ + +``tailbone.grids`` +================== + +.. automodule:: tailbone.grids + :members: diff --git a/docs/api/progress.rst b/docs/api/progress.rst new file mode 100644 index 00000000..83685d47 --- /dev/null +++ b/docs/api/progress.rst @@ -0,0 +1,6 @@ + +``tailbone.progress`` +===================== + +.. automodule:: tailbone.progress + :members: diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst new file mode 100644 index 00000000..d28a1b15 --- /dev/null +++ b/docs/api/subscribers.rst @@ -0,0 +1,6 @@ + +``tailbone.subscribers`` +======================== + +.. automodule:: tailbone.subscribers + :members: diff --git a/docs/api/util.rst b/docs/api/util.rst new file mode 100644 index 00000000..35e66ed3 --- /dev/null +++ b/docs/api/util.rst @@ -0,0 +1,6 @@ + +``tailbone.util`` +================= + +.. automodule:: tailbone.util + :members: diff --git a/docs/api/views/batch.rst b/docs/api/views/batch.rst new file mode 100644 index 00000000..344d6bd8 --- /dev/null +++ b/docs/api/views/batch.rst @@ -0,0 +1,5 @@ + +``tailbone.views.batch`` +======================== + +.. automodule:: tailbone.views.batch diff --git a/docs/api/views/batch.vendorcatalog.rst b/docs/api/views/batch.vendorcatalog.rst new file mode 100644 index 00000000..4df51685 --- /dev/null +++ b/docs/api/views/batch.vendorcatalog.rst @@ -0,0 +1,10 @@ + +``tailbone.views.batch.vendorcatalog`` +====================================== + +.. automodule:: tailbone.views.batch.vendorcatalog + +.. autoclass:: VendorCatalogsView + :members: + +.. autofunction:: includeme diff --git a/docs/api/views/core.rst b/docs/api/views/core.rst new file mode 100644 index 00000000..8a68f33f --- /dev/null +++ b/docs/api/views/core.rst @@ -0,0 +1,6 @@ + +``tailbone.views.core`` +======================= + +.. automodule:: tailbone.views.core + :members: diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst new file mode 100644 index 00000000..e7de7170 --- /dev/null +++ b/docs/api/views/master.rst @@ -0,0 +1,126 @@ + +``tailbone.views.master`` +========================= + +.. module:: tailbone.views.master + +Model Master View +------------------ + +This module contains the "model master" view class. This is a convenience +abstraction which provides some patterns/consistency for the typical set of +views needed to expose a table's data for viewing/editing/etc. Usually this +means providing something like the following view methods for a model: + +* index (list/filter) +* create +* view +* edit +* delete + +The actual list of provided view methods will depend on usage. Generally +speaking, each view method which is provided by the master class may be +configured in some way by the subclass (e.g. add extra filters to a grid). + +.. autoclass:: MasterView + + .. automethod:: index + + .. automethod:: create + + .. automethod:: view + + .. automethod:: edit + + .. automethod:: delete + +Attributes to Override +---------------------- + +The following is a list of attributes which you can (and in some cases must) +override when defining your subclass. + + .. attribute:: MasterView.model_class + + All master view subclasses *must* define this attribute. Its value must + be a data model class which has been mapped via SQLAlchemy, e.g. + ``rattail.db.model.Product``. + + .. attribute:: MasterView.normalized_model_name + + Name of the model class which has been "normalized" for the sake of usage + as a key (for grid settings etc.). If not defined by the subclass, the + default will be the lower-cased model class name, e.g. 'product'. + + .. attribute:: grid_key + + Unique value to be used as a key for the grid settings, etc. If not + defined by the subclass, the normalized model name will be used. + + .. attribute:: MasterView.route_prefix + + Value with which all routes provided by the view class will be prefixed. + If not defined by the subclass, a default will be constructed by simply + adding an 's' to the end of the normalized model name, e.g. 'products'. + + .. attribute:: MasterView.grid_factory + + Factory callable to be used when creating new grid instances; defaults to + :class:`tailbone.grids.Grid`. + + .. attribute:: MasterView.results_downloadable_csv + + Flag indicating whether the view should allow CSV download of grid data, + i.e. primary search results. + + .. attribute:: MasterView.help_url + + If set, this defines the "default" help URL for all views provided by the + master. Default value for this is simply ``None`` which would mean the + Help button is not shown at all. Note that the master may choose to + override this for certain views, if so that should be done within + :meth:`get_help_url()`. + + .. attribute:: MasterView.version_diff_factory + + Optional factory to use for version diff objects. By default + this is *not set* but a subclass is free to set it. See also + :meth:`get_version_diff_factory()`. + + +Methods to Override +------------------- + +The following is a list of methods which you can override when defining your +subclass. + + .. automethod:: MasterView.editable_instance + + .. .. automethod:: MasterView.get_settings + + .. automethod:: MasterView.get_csv_fields + + .. automethod:: MasterView.get_csv_row + + .. automethod:: MasterView.get_help_url + + .. automethod:: MasterView.get_model_key + + .. automethod:: MasterView.get_version_diff_enums + + .. automethod:: MasterView.get_version_diff_factory + + .. automethod:: MasterView.make_version_diff + + .. automethod:: MasterView.title_for_version + + +Support Methods +--------------- + +The following is a list of methods you should (probably) not need to +override, but may find useful: + + .. automethod:: MasterView.default_edit_url + + .. automethod:: MasterView.get_action_route_kwargs diff --git a/docs/api/views/members.rst b/docs/api/views/members.rst new file mode 100644 index 00000000..6a9e9168 --- /dev/null +++ b/docs/api/views/members.rst @@ -0,0 +1,6 @@ + +``tailbone.views.members`` +========================== + +.. automodule:: tailbone.views.members + :members: diff --git a/docs/api/views/purchasing.batch.rst b/docs/api/views/purchasing.batch.rst new file mode 100644 index 00000000..9bb62c8b --- /dev/null +++ b/docs/api/views/purchasing.batch.rst @@ -0,0 +1,9 @@ + +``tailbone.views.purchasing.batch`` +=================================== + +.. automodule:: tailbone.views.purchasing.batch + +.. autoclass:: PurchasingBatchView + + .. automethod:: save_edit_row_form diff --git a/docs/api/views/purchasing.ordering.rst b/docs/api/views/purchasing.ordering.rst new file mode 100644 index 00000000..38d46b07 --- /dev/null +++ b/docs/api/views/purchasing.ordering.rst @@ -0,0 +1,15 @@ + +``tailbone.views.purchasing.ordering`` +====================================== + +.. automodule:: tailbone.views.purchasing.ordering + +.. autoclass:: OrderingBatchView + + .. autoattribute:: model_class + + .. autoattribute:: default_handler_spec + + .. automethod:: configure_row_form + + .. automethod:: worksheet_update diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 00000000..bbf94f4b --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,8 @@ + +Changelog Archive +================= + +.. toctree:: + :maxdepth: 1 + + OLDCHANGES diff --git a/docs/concepts/batches.rst b/docs/concepts/batches.rst new file mode 100644 index 00000000..bdf66b11 --- /dev/null +++ b/docs/concepts/batches.rst @@ -0,0 +1,65 @@ + +Data Batches +============ + +.. contents:: :local: + +Data "batches" are one of the most powerful features of Rattail / Tailbone. +However each "batch type" is different, and they usually require custom +development. In all cases they require a Rattail-based app database, for +storage. + + +General Overview +---------------- + +You can think of data batches as a sort of "temporary spreadsheet" feature. +When a batch is created, it is usually populated with rows, from some data +source. The user(s) may then manipulate the batch data as needed, with the +final goal being to "execute" the batch. What execution specifically means +will depend on context, e.g. type of batch, but generally it will "commit" the +"pending changes" which are represented by the batch. + +Note that when a batch is executed, it becomes read-only ("frozen in time") and +at that point may be considered part of an audit trail of sorts. The utility +of this may vary depending on the nature of the batch data. + +Beyond that it's difficult to describe batches very well at this level, +precisely because they're all different. + +.. + This graphic tries to show how batches are created and executed over time. + Note that each batch type is free to target a different system(s) upon + execution. + + TODO: need graphic + + +Batch Tables +------------ + +In most cases the table(s) underlying a particular batch type, have a "static" +schema and must be defined as ORM classes, e.g. within the ``poser.db.model`` +package. + +In some rare cases the batch data (row) table may be dynamic; however the batch +header table must still be defined. + + +Batch Handlers +-------------- + +Once the batch table(s) are present, the next puzzle piece is the batch +handler. Again there is generally (at least) one handler defined for each +batch type. + +The batch "handler" is considered part of the data layer and provides logic for +populating the batch, executing it etc. + + +Batch Views +----------- + +This discussion would not be complete without mentioning the web views for the +batch. Again each batch type will require a custom view(s) although these +"usually" are simple wrappers as most logic is provided by the base view. diff --git a/docs/concepts/config.rst b/docs/concepts/config.rst new file mode 100644 index 00000000..9d7eacb3 --- /dev/null +++ b/docs/concepts/config.rst @@ -0,0 +1,115 @@ + +Configuration +============= + +.. contents:: :local: + +Configuration for an app can come from two sources: configuration file(s), and +the Settings table in the database. + + +Config File Inheritance +----------------------- + +An important thing to understand regarding Rattail config files, is that one +file may "include" another file(s), which in turn may "include" others etc. +Invocation of the app will often require only a single config file to be +specified, since that file may include others as needed. + +For example ``web.conf`` will typically include ``rattail.conf`` but the web +app need only be invoked with ``web.conf`` - config from both files will inform +the app's behavior. + + +Typical Config Files +-------------------- + +A typical Poser (Rattail-based) app will have at the very least, one file named +``rattail.conf`` - this is considered the most fundamental config file. It +will usually define database connections, logging config, and any other "core" +things which would be required for any invocation of the app, regardless of the +environment (e.g. console vs. web). + +Note that even ``rattail.conf`` is free to include other files. This may be +useful for instance, if you have a single site-wide config file which is shared +among all Rattail apps. + +There is no *strict* requirement for having a ``rattail.conf`` file, but these +docs will assume its presence. Here are some other typical files, which the +docs also may reference occasionally: + +**web.conf** - This is the "core" config file for the web app, although it +still includes the ``rattail.conf`` file. In production (running on Apache +etc.) it is specified within the WSGI module which is responsible for +instantiating the web app. When running the development server, it is +specified via command line. + +**quiet.conf** - This is a slight wrapper around ``rattail.conf`` for the sake +of a "quieter" console, when running app commands via console. It may be used +in place of ``rattail.conf`` - i.e. you would specify ``-c quiet.conf`` when +running the command. The only function of this wrapper is to set the level to +INFO for the console logging handler. In practice this hides DEBUG logging +messages which are shown by default when using ``rattail.conf`` as the app +config file. + +**cron.conf** - Another wrapper around ``rattail.conf`` which suppresses +logging even further. The idea is that this config file would be used by cron +jobs; that way the only actual output is warnings and errors, hence cron would +not send email unless something actually went wrong. It may be used in place +of ``rattail.conf`` - i.e. you would specify ``-c cron.conf`` when running the +command. The only function of this wrapper is to set the level to WARNING for +the console logging handler. + +**ignore-changes.conf** - This file is only relevant if your ``rattail.conf`` +says to "record changes" when write activity occurs in the database(s). Note +that this file does *not* include ``rattail.conf`` because it is meant to be +supplemental only. For instance on the command line, you would need to specify +two config files, first ``rattail.conf`` or a suitable alternative, but then +``ignore-changes.conf`` also. If specified, this file will cause changes to be +ignored, i.e. **not recorded** when write activity occurs. + +**without-versioning.conf** - This file is only relevant if your +``rattail.conf`` says to enable "data versioning" when write activity occurs in +the database(s). Note that this file does *not* include ``rattail.conf`` +because it is meant to be supplemental only. For instance on the command line, +you would need to specify two config files, first ``rattail.conf`` or a +suitable alternative, but then ``without-versioning.conf`` also. If specified, +this file will disable the data versioning system entirely. Note that if +versioning is undesirable for a given app run, this is the only way to +effectively disable it; once loaded that feature cannot be disabled. + + +Settings from Database +---------------------- + +The other (often more convenient) source of app configuration is the Settings +table within the app database. Whether or not this table is a valid source for +app configuration, ultimately depends on what the config file(s) has to say +about it. + +Assuming the config file(s) defines a database connection and declares it a +valid source for config values, then the Settings table may contribute to the +running app config. The nice thing about this is that these settings are +checked in real-time. So whereas changing a config file will require an app +restart, any edits to the settings table should take effect immediately. + +Usually the settings table will *override* values found in the config file. +This behavior also is configurable to some extent, and in some cases a config +value may *only* come from a config file and never the settings table. + +An example may help here. If the config file contained the following value: + +.. code-block:: ini + + [poser] + foo = bar + +Then you could create a new Setting in the database with the following fields: + +* **name** = poser.foo +* **value** = baz + +Assuming typical setup, i.e. where settings table may override config file, the +app would consider 'baz' to be the config value. So basically the setting name +must correspond to a combination of the config file "section" name, then a dot, +then the "option" name. diff --git a/docs/concepts/console.rst b/docs/concepts/console.rst new file mode 100644 index 00000000..32912d6a --- /dev/null +++ b/docs/concepts/console.rst @@ -0,0 +1,7 @@ + +Console Commands +================ + +.. contents:: :local: + +TODO diff --git a/docs/concepts/schema.rst b/docs/concepts/schema.rst new file mode 100644 index 00000000..f10436cb --- /dev/null +++ b/docs/concepts/schema.rst @@ -0,0 +1,45 @@ + +Database Schema +=============== + +.. contents:: :local: + +Rattail provides a "core" schema which is assumed to be the foundation of any +Poser app database. + + +Core Tables +----------- + +All tables which are considered part of the Rattail "core" schema, are defined +as ORM classes within the ``rattail.db.model`` package. + +.. note:: + + The Rattail project has its roots in retail grocery-type stores, and its + schema reflects that to a large degree. In practice however the software + may be used to support a wide variety of apps. The next section describes + that a bit more. + + +Customizing the Schema +---------------------- + +Almost certainly a custom app will need some of the core tables, but just as +certainly, it will *not* need others. And to make things even more +interesting, it may need some tables but also need to "supplement" them +somehow, to track additional data for each record etc. + +Any table in the core schema which is *not* needed, may simply be ignored, +i.e. hidden from the app UI etc. + +Any table which is "missing" from core schema, from the custom app's +perspective, should be added as a custom table. + +Also, any table which is "present but missing columns" from the app's +perspective, will require a custom table. In this case each record in the +custom table will "tie back to" the core table record. The custom record will +then supply any additional data for the core record. + +Defining custom tables, and associated tasks, are documented in +:doc:`../schemachange`. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..ade4c92a --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,52 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +from importlib.metadata import version as get_version + +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 + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.todo', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +intersphinx_mapping = { + '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), +} + +# allow todo entries to show up +todo_include_todos = True + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +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' + +# Output file base name for HTML help builder. +#htmlhelp_basename = 'Tailbonedoc' diff --git a/docs/devenv.rst b/docs/devenv.rst new file mode 100644 index 00000000..c8900f60 --- /dev/null +++ b/docs/devenv.rst @@ -0,0 +1,78 @@ + +Development Environment +======================= + +.. contents:: :local: + +Base System +----------- + +Development for Tailbone in particular is assumed to occur on a Linux machine. +This is because it's assumed that the web app would run on Linux. It should be +possible (presumably) to do either on Windows or Mac but that is not officially +supported. + +Furthermore it is assumed the Linux flavor in use is either Debian or Ubuntu, +or a similar alternative. Presumably any Linux would work although some +details may differ from what's shown here. + +Prerequisites +------------- + +Python +^^^^^^ + +The only supported Python is 2.7. Of course that should already be present on +Linux. + +It usually is required at some point to compile C code for certain Python +extension modules. In practice this means you probably want the Python header +files as well: + +.. code-block:: sh + + sudo apt-get install python-dev + +pip +^^^ + +The only supported Python package manager is ``pip``. This can be installed a +few ways, one of which is: + +.. code-block:: sh + + sudo apt-get install python-pip + +virtualenvwrapper +^^^^^^^^^^^^^^^^^ + +While not technically required, it is recommended to use ``virtualenvwrapper`` +as well. There is more than one way to set this up, e.g.: + +.. code-block:: sh + + sudo apt-get install python-virtualenvwrapper + +The main variable as concerns these docs, is where your virtual environment(s) +will live. If you install virtualenvwrapper via the above command, then most +likely your ``$WORKON_HOME`` environment variable will be set to +``~/.virtualenvs`` - however these docs will assume ``/srv/envs`` instead. +Please adjust any commands as needed. + +PostgreSQL +^^^^^^^^^^ + +The other primary requirement is PostgreSQL. Technically that may be installed +on a separate machine, which allows connection from the development machine. +But of course it will usually just be installed on the dev machine: + +.. code-block:: sh + + sudo apt-get install postgresql + +Regardless of where your PG server lives, you will probably need some extras in +order to compile extensions for the ``psycopg2`` package: + +.. code-block:: sh + + sudo apt-get install libpq-dev diff --git a/docs/images/poser-architecture.png b/docs/images/poser-architecture.png new file mode 100644 index 00000000..7e697990 Binary files /dev/null and b/docs/images/poser-architecture.png differ diff --git a/docs/images/rattail_avatar.png b/docs/images/rattail_avatar.png new file mode 100644 index 00000000..99640af3 Binary files /dev/null and b/docs/images/rattail_avatar.png differ diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..d964086f --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,85 @@ + +Tailbone +======== + +Welcome to Tailbone, part of the Rattail project. While the core Rattail +package provides the data layer, the Tailbone package provides the (default, +back-end) web application layer. + +Some additional information is available on the `website`_. Certainly not +everything is documented yet, but here you can see what has received some +attention thus far. + +.. _website: https://rattailproject.org/ + +Quick Start for Custom Apps: + +.. toctree:: + :maxdepth: 1 + + structure + devenv + newproject + schemachange + +Concept Guide: + +.. toctree:: + + concepts/config + concepts/console + concepts/schema + concepts/batches + +Narrative Documentation: + +.. toctree:: + + narr/batches + +Package API: + +.. toctree:: + :maxdepth: 1 + + api/api/batch/core + api/api/batch/ordering + api/db + api/diffs + api/forms + api/forms.widgets + api/grids + api/grids.core + api/progress + api/subscribers + api/util + api/views/batch + api/views/batch.vendorcatalog + api/views/core + api/views/master + api/views/members + api/views/purchasing.batch + api/views/purchasing.ordering + + +Changelog: + +.. toctree:: + :maxdepth: 1 + + changelog + + +Documentation To-Do +=================== + +.. todolist:: + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..c2898264 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,242 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Tailbone.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Tailbone.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/docs/narr/batches.rst b/docs/narr/batches.rst new file mode 100644 index 00000000..a3c9b372 --- /dev/null +++ b/docs/narr/batches.rst @@ -0,0 +1,74 @@ +.. -*- coding: utf-8 -*- + +Data Batches +============ + +This document briefly outlines what comprises a batch in terms of the Tailbone +user interface etc. + + +Batch Views +----------- + +Adding support for a new batch type is mostly a matter of providing some custom +views for the batch and its rows. In fact you must define four different view +classes, inheriting from each of the following: + +* :class:`tailbone.views.batch.BatchGrid` +* :class:`tailbone.views.batch.BatchCrud` +* :class:`tailbone.views.batch.BatchRowGrid` +* :class:`tailbone.views.batch.BatchRowCrud` + +It would sure be nice to only require two view classes instead of four, hopefully +that can happen "soon". In the meantime that's what it takes. Note that as with +batch data models, there are some more specialized parent classes which you may +want to inherit from instead of the core classes mentioned above: + +* :class:`tailbone.views.batch.FileBatchGrid` +* :class:`tailbone.views.batch.FileBatchCrud` +* :class:`tailbone.views.batch.ProductBatchRowGrid` + +Here are the vendor catalog views as examples: + +* :class:`tailbone.views.vendors.catalogs.VendorCatalogGrid` +* :class:`tailbone.views.vendors.catalogs.VendorCatalogCrud` +* :class:`tailbone.views.vendors.catalogs.VendorCatalogRowGrid` +* :class:`tailbone.views.vendors.catalogs.VendorCatalogRowCrud` + + +Pyramid Config +-------------- + +In addition to defining the batch views, the Pyramid Configurator object must be +told of the views and their routes. This also could probably stand to be simpler +somehow, but for now the easiest thing is to apply default configuration with: + +* :func:`tailbone.views.batch.defaults()` + +See the source behind the vendor catalog for an example: + +* :func:`tailbone.views.vendors.catalogs.includeme()` + +Note of course that your view config must be included by the core/upstream +config process of your application's startup to take effect. At this point +your views should be accessible by navigating to the URLs directly, e.g. for +the vendor catalog views: + +* List Uploaded Catalogs - http://example.com/vendors/catalogs/ +* Upload New Catlaog - http://example.com/vendors/catalogs/new + + +Menu and Templates +------------------ + +Providing access to the batch views is (I think) the last step. You must add +links to the views, wherever that makes sense for your app. In case it's +helpful, here's a Mako template snippet which would show some links to the main +vendor catalog views: + +.. code-block:: mako + +
    +
  • ${h.link_to("Vendor Catalogs", url('vendors.catalogs'))}
  • +
  • ${h.link_to("Upload new Vendor Catalog", url('vendors.catalogs.create'))}
  • +
diff --git a/docs/newproject.rst b/docs/newproject.rst new file mode 100644 index 00000000..30aaae89 --- /dev/null +++ b/docs/newproject.rst @@ -0,0 +1,154 @@ + +Creating a New Project +====================== + +.. contents:: :local: + +.. highlight:: bash + +This describes the process of creating a new app project based on +Rattail/Tailbone. It assumes you are working from a supported :doc:`devenv`. + +Per convention, this doc uses "Poser" (and ``poser``) to represent the custom +app. Please adjust commands etc. accordingly. See also :doc:`structure`. + + +Create the Virtual Environment +------------------------------ + +First step is simple enough:: + + mkvirtualenv poser + +Then with your new environment activated, install the Tailbone package:: + + pip install Tailbone + + +Create the Project +------------------ + +Now with your environment still activated, ``cd`` to wherever you like +(e.g. ``~/src``) and create a new project skeleton like so:: + + mkdir -p ~/src + cd ~/src + pcreate -s rattail poser + +This will have created a new project at ``~/src/poser`` which you can then edit +as you wish. At some point you will need to "install" this project to the +environment like so (again with environment active):: + + cd ~/src/poser + pip install -e . + + +Setup the App Environment +------------------------- + +Any project based on Rattail will effectively be its own "app" (usually), but +Rattail itself provides some app functionality as well. However all such apps +require config files, usually. If running a web app then you may also need to +have configured a folder for session storage, etc. To hopefully simplify all +this, there are a few commands you should now run, with your virtual +environment still active:: + + rattail make-appdir + cdvirtualenv app + rattail make-config -T rattail + rattail make-config -T quiet + rattail make-config -T web + +This will have created a new 'app' folder in your environment (e.g. at +``/srv/envs/poser/app``) and then created ``rattail.conf`` and ``web.conf`` +files within that app dir. Note that there will be other folders inside the +app dir as well; these are referenced by the config files. + +But you're not done yet... You should likely edit the config files, at the +very least edit ``rattail.conf`` and change the ``default.url`` value (under +``[rattail.db]`` section) which defines the Rattail database connection. + + +Create the Database +------------------- + +If applicable, it's time for that. First you must literally create the user +and database on your PostgreSQL server, e.g.:: + + sudo -u postgres createuser --no-createdb --no-createrole --no-superuser poser + sudo -u postgres psql -c "alter user poser password 'mypassword'" + sudo -u postgres createdb --owner poser poser + +Then you can install the schema; with your virtual environment activated:: + + cdvirtualenv + alembic -c app/rattail.conf upgrade heads + +At this point your 'poser' database should have some empty tables. To confirm, +on your PG server do:: + + sudo -u postgres psql -c '\d' poser + + +Create Admin User +----------------- + +If your intention is to have a web app, or at least to test one, you'll +probably want to create the initial admin user. With your env active:: + + cdvirtualenv + rattail -c app/quiet.conf make-user --admin myusername + +This should prompt you for a password, then create a single user and assign it +to the Administrator role. + + +Install Sample Data +------------------- + +If desired, you can install a bit of sample data to your fresh Rattail +database. With your env active do:: + + cdvirtualenv + rattail -c app/quiet.conf -P import-sample + + +Run Dev Web Server +------------------ + +With all the above in place, you may now run the web server in dev mode:: + + cdvirtualenv + pserve --reload app/web.conf + +And finally..you may browse your new project dev site at http://localhost:9080/ +(unless you changed the port etc.) + + +Schema Migrations +----------------- + +Often a new project will require custom schema additions to track/manage data +unique to the project. Rattail uses `Alembic`_ for handling schema migrations. +General usage of that is documented elsewhere, but a little should be said here +regarding new projects. + +.. _Alembic: https://pypi.python.org/pypi/alembic + +The new project template includes most of an Alembic "repo" for schema +migrations. However there is one step required to really bootstrap it, i.e. to +the point where normal Alembic usage will work: you must create the initial +version script. Before you do this, you should be reasonably happy with any +ORM classes you've defined, as the initial version script will be used to +create that schema. Once you're ready for the script, this command should do +it:: + + cdvirtualenv + bin/alembic -c app/rattail.conf revision --autogenerate --version-path ~/src/poser/poser/db/alembic/versions/ -m 'initial Poser tables' + +You should of course look over and edit the generated script as needed. One +change in particular you should make is to add a branch label, e.g.: + +.. code-block:: python + + branch_labels = ('poser',) diff --git a/docs/schemachange.rst b/docs/schemachange.rst new file mode 100644 index 00000000..5d385b7b --- /dev/null +++ b/docs/schemachange.rst @@ -0,0 +1,63 @@ + +Migrating the Schema +==================== + +.. contents:: :local: + +As development progresses for your custom app, you may need to migrate the +database schema from time to time. + +See also this general discussion of the :doc:`concepts/schema`. + +.. note:: + + The only "safe" migrations are those which add or modify (or remove) + "custom" tables, i.e. those *not* provided by the ``rattail.db.model`` + package. This doc assumes you are aware of this and are only attempting a + safe migration. + + +Modify ORM Classes +------------------ + +First step is to modify the ORM classes defined by your app, so they reflect +the "desired" schema. Typically this will mean editing files under the +``poser.db.model`` package within your source. In particular when adding new +tables, you must be sure to include them within ``poser/db/model/__init__.py``. + +As noted above, only those classes *not* provided by ``rattail.db.model`` +should be modified here, to be safe. If you wish to "extend" an existing +table, you must create a secondary table which ties back to the first via +one-to-one foreign key relationship. + + +Create Migration Script +----------------------- + +Next you will create the Alembic script which is responsible for performing the +schema migration against a database. This is typically done like so: + +.. code-block:: sh + + workon poser + cdvirtualenv + bin/alembic -c app/rattail.conf revision --autogenerate --head poser@head -m "describe migration here" + +This will create a new file under +e.g. ``~/src/poser/poser/db/alembic/versions/``. You should edit this file as +needed to ensure it performs all steps required for the migration. Technically +it should support downgrade as well as upgrade, although in practice that isn't +always required. + + +Upgrade Database Schema +----------------------- + +Once you're happy with the new script, you can apply it against your dev +database with something like: + +.. code-block:: sh + + workon poser + cdvirtualenv + bin/alembic -c app/rattail.conf upgrade heads diff --git a/docs/structure.rst b/docs/structure.rst new file mode 100644 index 00000000..5585f71a --- /dev/null +++ b/docs/structure.rst @@ -0,0 +1,130 @@ + +App Organization & Structure +============================ + +.. contents:: :local: + +Tailbone doesn't try to be an "app" proper. But it does try to provide just +about everything you'd need to make one. These docs assume you are making a +custom app, and will refer to the app as "Poser" to be consistent. In practice +you would give your app a unique name which is meaningful to you. Please +mentally replace "Poser" with your app name as you read. + +.. note:: + + Technically it *is possible* to use Tailbone directly as the app. You may + do so for basic testing of the concepts, but you'd be stuck with Tailbone + logic, with far fewer customization options. All docs will assume a custom + "Poser" app which wraps and (as necessary) overrides Tailbone and Rattail. + + +Architecture +------------ + +In terms of how the Poser app hangs together, here is a conceptual diagram. +Note that all systems on the right-hand side are *external* to Poser, i.e. they +are not "plugins" although Poser may use plugin-like logic for the sake of +integrating with these systems. + +.. image:: images/poser-architecture.png + + +Data Layer vs. Web Layer +^^^^^^^^^^^^^^^^^^^^^^^^ + +While the above graphic doesn't do a great job highlighting the difference, it +will (presumably) help to understand the difference in purpose and function of +Tailbone vs. Rattail packages. + +**Rattail** is the data layer, and is responsible for database connectivity, +table schema information, and some business rules logic (among other things). + +**Tailbone** is the web app layer, and is responsible for presentation and +management of data objects which are made available by Rattail (and others). + +**Poser** is a custom layer which can make use of both data and web app layers, +supplementing each as necessary. In practice the lines may get blurry within +Poser. + +The reason for this distinction between layers, is to allow creation of custom +apps which use only the data layer but not the web app layer. This can be +useful for console-based apps; a traditional GUI app would also be possible +although none is yet planned. + + +File Layout +----------- + +Below is an example file layout for a Poser app project. This tries to be +"complete" and show most kinds of files a typical project may need. In +practice you can usually ignore anything which doesn't apply to your app, +i.e. relatively few of the files shown here are actually required. Of course +some apps may need many more files than this to achieve their goals. + +Note that all files in the root ``poser`` package namespace would correspond to +the "data layer" mentioned above, whereas everything under ``poser.web`` would +of course supply the web app layer. + +.. code-block:: none + + ~/src/poser/ + ├── CHANGELOG.md + ├── docs/ + ├── fabfile.py + ├── MANIFEST.in + ├── poser/ + │   ├── __init__.py + │   ├── batch/ + │   │   ├── __init__.py + │   │   └── foobatch.py + │   ├── commands.py + │   ├── config.py + │   ├── datasync/ + │   ├── db/ + │   │   ├── __init__.py + │   │   ├── alembic/ + │   │   └── model/ + │   │   ├── __init__.py + │   │   ├── batch/ + │   │   │   ├── __init__.py + │   │   │   └── foobatch.py + │   │   └── customers.py + │   ├── emails.py + │   ├── enum.py + │   ├── importing/ + │   │   ├── __init__.py + │   │   ├── model.py + │   │   ├── poser.py + │   │   └── versions.py + │   ├── problems.py + │   ├── templates/ + │   │   └── mail/ + │   │   └── warn_about_foo.html.mako + │   ├── _version.py + │   └── web/ + │   ├── __init__.py + │   ├── app.py + │   ├── static/ + │   │   ├── __init__.py + │   │   ├── css/ + │   │   ├── favicon.ico + │   │   ├── img/ + │   │   └── js/ + │   ├── subscribers.py + │   ├── templates/ + │   │   ├── base.mako + │   │   ├── batch/ + │   │   │   └── foobatch/ + │   │   ├── customers/ + │   │   ├── menu.mako + │   │   └── products/ + │   └── views/ + │   ├── __init__.py + │   ├── batch/ + │   │   ├── __init__.py + │   │   └── foobatch.py + │   ├── common.py + │   ├── customers.py + │   └── products.py + ├── README.rst + └── setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..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/rattail/__init__.py b/rattail/__init__.py deleted file mode 100644 index 3ad9513f..00000000 --- a/rattail/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from pkgutil import extend_path -__path__ = extend_path(__path__, __name__) diff --git a/rattail/pyramid/__init__.py b/rattail/pyramid/__init__.py deleted file mode 100644 index e188d283..00000000 --- a/rattail/pyramid/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid`` -- Rattail's Pyramid Framework -""" - -from rattail.pyramid._version import __version__ - - -def includeme(config): - config.include('rattail.pyramid.subscribers') - config.include('rattail.pyramid.views') diff --git a/rattail/pyramid/_version.py b/rattail/pyramid/_version.py deleted file mode 100644 index 78843b1b..00000000 --- a/rattail/pyramid/_version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '0.3a6' diff --git a/rattail/pyramid/forms.py b/rattail/pyramid/forms.py deleted file mode 100644 index b0d2b067..00000000 --- a/rattail/pyramid/forms.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.forms`` -- Rattail Forms -""" - -from webhelpers.html import literal - -import formalchemy - -from edbob.pyramid.forms import pretty_datetime - -import rattail -from rattail.gpc import GPC - - -class GPCFieldRenderer(formalchemy.TextFieldRenderer): - """ - Renderer for :class:`rattail.gpc.GPC` fields. - """ - - @property - def length(self): - # Hm, should maybe consider hard-coding this...? - return len(str(GPC(0))) - - -class PriceFieldRenderer(formalchemy.FieldRenderer): - """ - Renderer for fields which reference a :class:`ProductPrice` instance. - """ - - def render_readonly(self, **kwargs): - price = self.field.raw_value - if price: - if price.price is not None and price.pack_price is not None: - if price.multiple > 1: - return literal('$ %0.2f / %u  ($ %0.2f / %u)' % ( - price.price, price.multiple, - price.pack_price, price.pack_multiple)) - return literal('$ %0.2f  ($ %0.2f / %u)' % ( - price.price, price.pack_price, price.pack_multiple)) - if price.price is not None: - if price.multiple > 1: - return '$ %0.2f / %u' % (price.price, price.multiple) - return '$ %0.2f' % price.price - if price.pack_price is not None: - return '$ %0.2f / %u' % (price.pack_price, price.pack_multiple) - return '' - - -class PriceWithExpirationFieldRenderer(PriceFieldRenderer): - """ - Price field renderer which also displays the expiration date, if present. - """ - - def render_readonly(self, **kwargs): - res = super(PriceWithExpirationFieldRenderer, self).render_readonly(**kwargs) - if res: - price = self.field.raw_value - if price.ends: - res += '  (%s)' % pretty_datetime(price.ends, from_='utc') - return res diff --git a/rattail/pyramid/subscribers.py b/rattail/pyramid/subscribers.py deleted file mode 100644 index b28ab180..00000000 --- a/rattail/pyramid/subscribers.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.subscribers`` -- Event Subscribers -""" - -from pyramid import threadlocal - -import rattail - - -def before_render(event): - """ - Adds goodies to the global template renderer context: - - * ``rattail`` - """ - - # Import labels module so it's available if/when needed. - import rattail.labels - - # Import SIL module so it's available if/when needed. - import rattail.sil - - request = event.get('request') or threadlocal.get_current_request() - - renderer_globals = event - renderer_globals['rattail'] = rattail - - -def includeme(config): - config.add_subscriber('rattail.pyramid.subscribers:before_render', - 'pyramid.events.BeforeRender') diff --git a/rattail/pyramid/templates/batches/crud.mako b/rattail/pyramid/templates/batches/crud.mako deleted file mode 100644 index 165b02be..00000000 --- a/rattail/pyramid/templates/batches/crud.mako +++ /dev/null @@ -1,8 +0,0 @@ -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Batches", url('batches'))}
  • -
  • ${h.link_to("View Batch Rows", url('batch.rows', uuid=form.fieldset.model.uuid))}
  • - - -${parent.body()} diff --git a/rattail/pyramid/templates/batches/index.mako b/rattail/pyramid/templates/batches/index.mako deleted file mode 100644 index 8b426e03..00000000 --- a/rattail/pyramid/templates/batches/index.mako +++ /dev/null @@ -1,5 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Batches - -${parent.body()} diff --git a/rattail/pyramid/templates/batches/params.mako b/rattail/pyramid/templates/batches/params.mako deleted file mode 100644 index 48a494fa..00000000 --- a/rattail/pyramid/templates/batches/params.mako +++ /dev/null @@ -1,42 +0,0 @@ -<%inherit file="/base.mako" /> - -<%def name="title()">Batch Parameters - -<%def name="head_tags()"> - ${parent.head_tags()} - - - -<%def name="batch_params()"> - -

    Please provide the following values for your new batch:

    -
    - -
    - - ${h.form(request.get_referrer())} - ${h.hidden('provider', value=provider)} - ${h.hidden('params', value='True')} - - ${self.batch_params()} - -
    - - -
    - - ${h.end_form()} - -
    diff --git a/rattail/pyramid/templates/batches/params/print_labels.mako b/rattail/pyramid/templates/batches/params/print_labels.mako deleted file mode 100644 index c559c767..00000000 --- a/rattail/pyramid/templates/batches/params/print_labels.mako +++ /dev/null @@ -1,19 +0,0 @@ -<%inherit file="/batches/params.mako" /> - -<%def name="batch_params()"> - -
    - -
    - ${h.select('profile', None, label_profiles)} -
    -
    - -
    - -
    ${h.text('quantity', value=1)}
    -
    - - - -${parent.body()} diff --git a/rattail/pyramid/templates/batches/read.mako b/rattail/pyramid/templates/batches/read.mako deleted file mode 100644 index 3ce97fea..00000000 --- a/rattail/pyramid/templates/batches/read.mako +++ /dev/null @@ -1,40 +0,0 @@ -<%inherit file="/batches/crud.mako" /> - -<%def name="context_menu_items()"> - ${parent.context_menu_items()} -
  • ${h.link_to("Edit this Batch", url('batch.update', uuid=form.fieldset.model.uuid))}
  • -
  • ${h.link_to("Delete this Batch", url('batch.delete', uuid=form.fieldset.model.uuid))}
  • - - -${parent.body()} - -<% batch = form.fieldset.model %> - -

    Columns

    - -
    - - - - - - - - - - - - - % for i, column in enumerate(batch.columns, 1): - - - - - - - - - % endfor - -
    NameSIL NameDisplay NameDescriptionData TypeVisible
    ${column.name}${column.sil_name}${column.display_name}${column.description}${column.data_type}${column.visible}
    -
    diff --git a/rattail/pyramid/templates/batches/rows/crud.mako b/rattail/pyramid/templates/batches/rows/crud.mako deleted file mode 100644 index c5a0daf0..00000000 --- a/rattail/pyramid/templates/batches/rows/crud.mako +++ /dev/null @@ -1,8 +0,0 @@ -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Batch", url('batch', uuid=form.fieldset.model.batch.uuid))}
  • -
  • ${h.link_to("Back to Batch Rows", url('batch.rows', uuid=form.fieldset.model.batch.uuid))}
  • - - -${parent.body()} diff --git a/rattail/pyramid/templates/batches/rows/index.mako b/rattail/pyramid/templates/batches/rows/index.mako deleted file mode 100644 index 17c72786..00000000 --- a/rattail/pyramid/templates/batches/rows/index.mako +++ /dev/null @@ -1,46 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Batch Rows : ${batch.description} - -<%def name="head_tags()"> - ${parent.head_tags()} - - - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Batches", url('batches'))}
  • -
  • ${h.link_to("Back to Batch", url('batch', uuid=batch.uuid))}
  • - - -<%def name="tools()"> -
    - - -
    - - -${parent.body()} diff --git a/rattail/pyramid/templates/brands/crud.mako b/rattail/pyramid/templates/brands/crud.mako deleted file mode 100644 index 4da3d733..00000000 --- a/rattail/pyramid/templates/brands/crud.mako +++ /dev/null @@ -1,12 +0,0 @@ -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Brands", url('brands'))}
  • - % if form.readonly: -
  • ${h.link_to("Edit this Brand", url('brand.update', uuid=form.fieldset.model.uuid))}
  • - % elif form.updating: -
  • ${h.link_to("View this Brand", url('brand.read', uuid=form.fieldset.model.uuid))}
  • - % endif - - -${parent.body()} diff --git a/rattail/pyramid/templates/brands/index.mako b/rattail/pyramid/templates/brands/index.mako deleted file mode 100644 index eff4ce0a..00000000 --- a/rattail/pyramid/templates/brands/index.mako +++ /dev/null @@ -1,11 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Brands - -<%def name="context_menu_items()"> - % if request.has_perm('brands.create'): -
  • ${h.link_to("Create a new Brand", url('brand.create'))}
  • - % endif - - -${parent.body()} diff --git a/rattail/pyramid/templates/categories/base.mako b/rattail/pyramid/templates/categories/base.mako deleted file mode 100644 index 27f7dd90..00000000 --- a/rattail/pyramid/templates/categories/base.mako +++ /dev/null @@ -1,2 +0,0 @@ -<%inherit file="/base.mako" /> -${parent.body()} diff --git a/rattail/pyramid/templates/categories/crud.mako b/rattail/pyramid/templates/categories/crud.mako deleted file mode 100644 index 1e373321..00000000 --- a/rattail/pyramid/templates/categories/crud.mako +++ /dev/null @@ -1,10 +0,0 @@ -<%inherit file="/categories/base.mako" /> -<%inherit file="/crud.mako" /> - -<%def name="crud_name()">Category - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Categories", url('categories.list'))}
  • - - -${parent.body()} diff --git a/rattail/pyramid/templates/categories/edit.mako b/rattail/pyramid/templates/categories/edit.mako deleted file mode 100644 index 590cf3bb..00000000 --- a/rattail/pyramid/templates/categories/edit.mako +++ /dev/null @@ -1,2 +0,0 @@ -<%inherit file="/categories/crud.mako" /> -${parent.body()} diff --git a/rattail/pyramid/templates/categories/index.mako b/rattail/pyramid/templates/categories/index.mako deleted file mode 100644 index 3389f3b7..00000000 --- a/rattail/pyramid/templates/categories/index.mako +++ /dev/null @@ -1,12 +0,0 @@ -<%inherit file="/categories/base.mako" /> -<%inherit file="/index.mako" /> - -<%def name="title()">Categories - -<%def name="context_menu_items()"> - % if request.has_perm('categories.create'): -
  • ${h.link_to("Create a new Category", url('category.new'))}
  • - % endif - - -${parent.body()} diff --git a/rattail/pyramid/templates/categories/new.mako b/rattail/pyramid/templates/categories/new.mako deleted file mode 100644 index 590cf3bb..00000000 --- a/rattail/pyramid/templates/categories/new.mako +++ /dev/null @@ -1,2 +0,0 @@ -<%inherit file="/categories/crud.mako" /> -${parent.body()} diff --git a/rattail/pyramid/templates/customer_groups/crud.mako b/rattail/pyramid/templates/customer_groups/crud.mako deleted file mode 100644 index 0c0ba7cb..00000000 --- a/rattail/pyramid/templates/customer_groups/crud.mako +++ /dev/null @@ -1,7 +0,0 @@ -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Customer Groups", url('customer_groups'))}
  • - - -${parent.body()} diff --git a/rattail/pyramid/templates/customer_groups/index.mako b/rattail/pyramid/templates/customer_groups/index.mako deleted file mode 100644 index c7849cbb..00000000 --- a/rattail/pyramid/templates/customer_groups/index.mako +++ /dev/null @@ -1,5 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Customer Groups - -${parent.body()} diff --git a/rattail/pyramid/templates/customers/crud.mako b/rattail/pyramid/templates/customers/crud.mako deleted file mode 100644 index c8393e94..00000000 --- a/rattail/pyramid/templates/customers/crud.mako +++ /dev/null @@ -1,7 +0,0 @@ -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> -

    ${h.link_to("Back to Customers", url('customers'))}

    - - -${parent.body()} diff --git a/rattail/pyramid/templates/customers/index.mako b/rattail/pyramid/templates/customers/index.mako deleted file mode 100644 index 29394f2f..00000000 --- a/rattail/pyramid/templates/customers/index.mako +++ /dev/null @@ -1,5 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Customers - -${parent.body()} diff --git a/rattail/pyramid/templates/customers/read.mako b/rattail/pyramid/templates/customers/read.mako deleted file mode 100644 index c5cfa315..00000000 --- a/rattail/pyramid/templates/customers/read.mako +++ /dev/null @@ -1,51 +0,0 @@ -<%inherit file="/customers/crud.mako" /> - -${parent.body()} - -<% customer = form.fieldset.model %> - -

    People

    -% if customer.people: -

    Customer account is associated with the following people:

    -
    - - - - - - - % for i, person in enumerate(customer.people, 1): - - - - - % endfor - -
    First NameLast Name
    ${person.first_name or ''}${person.last_name or ''}
    -
    -% else: -

    Customer account is not associated with any people.

    -% endif - -

    Groups

    -% if customer.groups: -

    Customer account belongs to the following groups:

    -
    - - - - - - - % for i, group in enumerate(customer.groups, 1): - - - - - % endfor - -
    IDName
    ${group.id}${group.name or ''}
    -
    -% else: -

    Customer account doesn't belong to any groups.

    -% endif diff --git a/rattail/pyramid/templates/departments/index.mako b/rattail/pyramid/templates/departments/index.mako deleted file mode 100644 index c723848d..00000000 --- a/rattail/pyramid/templates/departments/index.mako +++ /dev/null @@ -1,5 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Departments - -${parent.body()} diff --git a/rattail/pyramid/templates/employees/index.mako b/rattail/pyramid/templates/employees/index.mako deleted file mode 100644 index b51b80b8..00000000 --- a/rattail/pyramid/templates/employees/index.mako +++ /dev/null @@ -1,5 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Employees - -${parent.body()} diff --git a/rattail/pyramid/templates/labels/profiles/crud.mako b/rattail/pyramid/templates/labels/profiles/crud.mako deleted file mode 100644 index e394346b..00000000 --- a/rattail/pyramid/templates/labels/profiles/crud.mako +++ /dev/null @@ -1,26 +0,0 @@ -<%inherit file="/crud.mako" /> - -<%def name="head_tags()"> - ${parent.head_tags()} - - - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Label Profiles", url('label_profiles'))}
  • - % if form.updating: - <% profile = form.fieldset.model %> - <% printer = profile.get_printer() %> - % if printer.required_settings: -
  • ${h.link_to("Edit Printer Settings", url('label_profile.printer_settings', uuid=profile.uuid))}
  • - % endif -
  • ${h.link_to("View this Label Profile", url('label_profile.read', uuid=profile.uuid))}
  • - % endif - - -${parent.body()} diff --git a/rattail/pyramid/templates/labels/profiles/index.mako b/rattail/pyramid/templates/labels/profiles/index.mako deleted file mode 100644 index a5383fbe..00000000 --- a/rattail/pyramid/templates/labels/profiles/index.mako +++ /dev/null @@ -1,11 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Label Profiles - -<%def name="context_menu_items()"> - % if request.has_perm('label_profiles.create'): -
  • ${h.link_to("Create a new Label Profile", url('label_profile.create'))}
  • - % endif - - -${parent.body()} diff --git a/rattail/pyramid/templates/labels/profiles/printer.mako b/rattail/pyramid/templates/labels/profiles/printer.mako deleted file mode 100644 index 9b1c5545..00000000 --- a/rattail/pyramid/templates/labels/profiles/printer.mako +++ /dev/null @@ -1,48 +0,0 @@ -<%inherit file="/base.mako" /> - -<%def name="title()">Printer Settings - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Label Profiles", url('label_profiles'))}
  • -
  • ${h.link_to("View this Label Profile", url('label_profile.read', uuid=profile.uuid))}
  • -
  • ${h.link_to("Edit this Label Profile", url('label_profile.update', uuid=profile.uuid))}
  • - - -
    - -
      - ${self.context_menu_items()} -
    - -
    - -
    - -
    ${profile.description}
    -
    - -
    - -
    ${profile.printer_spec}
    -
    - - ${h.form(request.current_route_url())} - - % for name, display in printer.required_settings.iteritems(): -
    - -
    - ${h.text(name, value=profile.get_printer_setting(name))} -
    -
    - % endfor - -
    - ${h.submit('update', "Update")} - -
    - - ${h.end_form()} -
    - -
    diff --git a/rattail/pyramid/templates/labels/profiles/read.mako b/rattail/pyramid/templates/labels/profiles/read.mako deleted file mode 100644 index c3e2cec7..00000000 --- a/rattail/pyramid/templates/labels/profiles/read.mako +++ /dev/null @@ -1,32 +0,0 @@ -<%inherit file="/labels/profiles/crud.mako" /> - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Label Profiles", url('label_profiles'))}
  • - % if form.readonly and request.has_perm('label_profiles.update'): - <% profile = form.fieldset.model %> - <% printer = profile.get_printer() %> -
  • ${h.link_to("Edit this Label Profile", url('label_profile.update', uuid=form.fieldset.model.uuid))}
  • - % if printer.required_settings: -
  • ${h.link_to("Edit Printer Settings", url('label_profile.printer_settings', uuid=profile.uuid))}
  • - % endif - % endif - - -${parent.body()} - -<% profile = form.fieldset.model %> -<% printer = profile.get_printer() %> - -% if printer.required_settings: -

    Printer Settings

    - -
    - % for name, display in printer.required_settings.iteritems(): -
    - -
    ${profile.get_printer_setting(name) or ''}
    -
    - % endfor -
    - -% endif diff --git a/rattail/pyramid/templates/products/batch.mako b/rattail/pyramid/templates/products/batch.mako deleted file mode 100644 index 335c8574..00000000 --- a/rattail/pyramid/templates/products/batch.mako +++ /dev/null @@ -1,27 +0,0 @@ -<%inherit file="/base.mako" /> - -<%def name="title()">Create Products Batch - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Products", url('products'))}
  • - - -
    - - ${h.form(request.current_route_url())} - -
    - -
    - ${h.select('provider', None, providers)} -
    -
    - -
    - ${h.submit('create', "Create Batch")} - -
    - - ${h.end_form()} - -
    diff --git a/rattail/pyramid/templates/products/crud.mako b/rattail/pyramid/templates/products/crud.mako deleted file mode 100644 index 27b773b8..00000000 --- a/rattail/pyramid/templates/products/crud.mako +++ /dev/null @@ -1,7 +0,0 @@ -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Products", url('products'))}
  • - - -${parent.body()} diff --git a/rattail/pyramid/templates/products/index.mako b/rattail/pyramid/templates/products/index.mako deleted file mode 100644 index dc09241c..00000000 --- a/rattail/pyramid/templates/products/index.mako +++ /dev/null @@ -1,104 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Products - -<%def name="head_tags()"> - ${parent.head_tags()} - - % if label_profiles and request.has_perm('products.print_labels'): - - % endif - - -<%def name="tools()"> - % if label_profiles and request.has_perm('products.print_labels'): - - - - - - - - - - - -
    LabelQty.
    - - - -
    - % endif - - -<%def name="context_menu_items()"> - % if request.has_perm('batches.create'): -
  • ${h.link_to("Create Batch from Results", url('products.create_batch'))}
  • - % endif - - -${parent.body()} diff --git a/rattail/pyramid/templates/products/read.mako b/rattail/pyramid/templates/products/read.mako deleted file mode 100644 index 86768505..00000000 --- a/rattail/pyramid/templates/products/read.mako +++ /dev/null @@ -1,37 +0,0 @@ -<%inherit file="/products/crud.mako" /> - -${parent.body()} - -<% product = form.fieldset.model %> - -
    -

    Product Costs:

    - % if product.costs: -
    - - - - - - - - - - - % for i, cost in enumerate(product.costs, 1): - - - - - - - - - % endfor - -
    Pref.VendorCodeCase SizeCase CostUnit Cost
    ${'X' if cost.preference == 1 else ''}${cost.vendor}${cost.code}${cost.case_size}${'$ %0.2f' % cost.case_cost if cost.case_cost is not None else ''}${'$ %0.4f' % cost.unit_cost if cost.unit_cost is not None else ''}
    -
    - % else: -

    None on file.

    - % endif -
    diff --git a/rattail/pyramid/templates/reports/base.mako b/rattail/pyramid/templates/reports/base.mako deleted file mode 100644 index 27f7dd90..00000000 --- a/rattail/pyramid/templates/reports/base.mako +++ /dev/null @@ -1,2 +0,0 @@ -<%inherit file="/base.mako" /> -${parent.body()} diff --git a/rattail/pyramid/templates/reports/ordering.mako b/rattail/pyramid/templates/reports/ordering.mako deleted file mode 100644 index 2eca252f..00000000 --- a/rattail/pyramid/templates/reports/ordering.mako +++ /dev/null @@ -1,84 +0,0 @@ -<%inherit file="/reports/base.mako" /> - -<%def name="title()">Report : Ordering Worksheet - -<%def name="head_tags()"> - ${parent.head_tags()} - - - -

    Please provide the following criteria to generate your report:

    -
    - -${h.form(request.current_route_url())} -${h.hidden('departments', value='')} - -
    - ${h.hidden('vendor', value='')} - - ${h.text('vendor-name', size='40', value='')} - -
    - -
    - -
    -
    - -
    - ${h.submit('submit', "Generate Report")} -
    - -${h.end_form()} - - diff --git a/rattail/pyramid/templates/stores/crud.mako b/rattail/pyramid/templates/stores/crud.mako deleted file mode 100644 index 7c690077..00000000 --- a/rattail/pyramid/templates/stores/crud.mako +++ /dev/null @@ -1,7 +0,0 @@ -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Stores", url('stores'))}
  • - - -${parent.body()} diff --git a/rattail/pyramid/templates/stores/index.mako b/rattail/pyramid/templates/stores/index.mako deleted file mode 100644 index d2e76951..00000000 --- a/rattail/pyramid/templates/stores/index.mako +++ /dev/null @@ -1,11 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Stores - -<%def name="context_menu_items()"> - % if request.has_perm('stores.create'): -
  • ${h.link_to("Create a new Store", url('store.create'))}
  • - % endif - - -${parent.body()} diff --git a/rattail/pyramid/templates/subdepartments/index.mako b/rattail/pyramid/templates/subdepartments/index.mako deleted file mode 100644 index 251b9c60..00000000 --- a/rattail/pyramid/templates/subdepartments/index.mako +++ /dev/null @@ -1,5 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Subdepartments - -${parent.body()} diff --git a/rattail/pyramid/templates/vendors/crud.mako b/rattail/pyramid/templates/vendors/crud.mako deleted file mode 100644 index a60036d9..00000000 --- a/rattail/pyramid/templates/vendors/crud.mako +++ /dev/null @@ -1,12 +0,0 @@ -<%inherit file="/crud.mako" /> - -<%def name="context_menu_items()"> -
  • ${h.link_to("Back to Vendors", url('vendors'))}
  • - % if form.readonly: -
  • ${h.link_to("Edit this Vendor", url('vendor.update', uuid=form.fieldset.model.uuid))}
  • - % elif form.updating: -
  • ${h.link_to("View this Vendor", url('vendor.read', uuid=form.fieldset.model.uuid))}
  • - % endif - - -${parent.body()} diff --git a/rattail/pyramid/templates/vendors/index.mako b/rattail/pyramid/templates/vendors/index.mako deleted file mode 100644 index 62d69078..00000000 --- a/rattail/pyramid/templates/vendors/index.mako +++ /dev/null @@ -1,11 +0,0 @@ -<%inherit file="/grid.mako" /> - -<%def name="title()">Vendors - -<%def name="context_menu_items()"> - % if request.has_perm('vendors.create'): -
  • ${h.link_to("Create a new Vendor", url('vendor.create'))}
  • - % endif - - -${parent.body()} diff --git a/rattail/pyramid/views/__init__.py b/rattail/pyramid/views/__init__.py deleted file mode 100644 index c853b560..00000000 --- a/rattail/pyramid/views/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views`` -- Pyramid Views -""" - - -def includeme(config): - config.include('rattail.pyramid.views.batches') - # config.include('rattail.pyramid.views.categories') - config.include('rattail.pyramid.views.customer_groups') - config.include('rattail.pyramid.views.customers') - config.include('rattail.pyramid.views.departments') - config.include('rattail.pyramid.views.employees') - config.include('rattail.pyramid.views.labels') - config.include('rattail.pyramid.views.products') - config.include('rattail.pyramid.views.stores') - config.include('rattail.pyramid.views.subdepartments') - config.include('rattail.pyramid.views.vendors') diff --git a/rattail/pyramid/views/batches/__init__.py b/rattail/pyramid/views/batches/__init__.py deleted file mode 100644 index 884fe329..00000000 --- a/rattail/pyramid/views/batches/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.batches`` -- Batch Views -""" - -from rattail.pyramid.views.batches.params import * - - -def includeme(config): - config.include('rattail.pyramid.views.batches.core') - config.include('rattail.pyramid.views.batches.params') - config.include('rattail.pyramid.views.batches.rows') diff --git a/rattail/pyramid/views/batches/core.py b/rattail/pyramid/views/batches/core.py deleted file mode 100644 index 5f255190..00000000 --- a/rattail/pyramid/views/batches/core.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.batches.core`` -- Core Batch Views -""" - -import threading - -from pyramid.httpexceptions import HTTPFound -from pyramid.renderers import render_to_response - -from webhelpers.html import tags - -import edbob -from edbob.pyramid import Session -from edbob.pyramid.forms import EnumFieldRenderer -from edbob.pyramid.progress import SessionProgress -from edbob.pyramid.views import SearchableAlchemyGridView, CrudView, View - -import rattail -from rattail import batches - - -class BatchesGrid(SearchableAlchemyGridView): - - mapped_class = rattail.Batch - config_prefix = 'batches' - sort = 'id' - - def filter_map(self): - return self.make_filter_map( - exact=['id'], - ilike=['source', 'destination', 'description']) - - def filter_config(self): - return self.make_filter_config( - filter_label_id="ID") - - def sort_map(self): - return self.make_sort_map('source', 'id', 'destination', 'description') - - def query(self): - q = self.make_query() - q = q.filter(rattail.Batch.executed == None) - return q - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.source, - g.id.label("ID"), - g.destination, - g.description, - g.rowcount.label("Row Count"), - ], - readonly=True) - if self.request.has_perm('batches.read'): - def rows(row): - return tags.link_to("View Rows", self.request.route_url( - 'batch.rows', uuid=row.uuid)) - g.add_column('rows', "", rows) - g.clickable = True - g.click_route_name = 'batch' - if self.request.has_perm('batches.update'): - g.editable = True - g.edit_route_name = 'batch.update' - if self.request.has_perm('batches.delete'): - g.deletable = True - g.delete_route_name = 'batch.delete' - return g - - -class BatchCrud(CrudView): - - mapped_class = rattail.Batch - home_route = 'batches' - - def fieldset(self, model): - fs = self.make_fieldset(model) - fs.action_type.set(renderer=EnumFieldRenderer(rattail.BATCH_ACTION)) - fs.configure( - include=[ - fs.source, - fs.id.label("ID"), - fs.destination, - fs.action_type, - fs.description, - fs.rowcount.label("Row Count").readonly(), - ]) - return fs - - def post_delete(self, batch): - batch.drop_table() - - -class ExecuteBatch(View): - - def execute_batch(self, batch): - session = edbob.Session() - batch = session.merge(batch) - - progress = SessionProgress(self.request.session, 'batch.execute') - progress.session['success_msg'] = "Batch \"%s\" has been executed." % batch.description - progress.session['success_url'] = self.request.route_url('batches') - - if batch.execute(progress): - session.commit() - else: - session.rollback() - session.close() - - def __call__(self): - uuid = self.request.matchdict['uuid'] - batch = Session.query(rattail.Batch).get(uuid) if uuid else None - if not batch: - return HTTPFound(location=self.request.route_url('batches')) - - thread = threading.Thread(target=self.execute_batch, args=(batch,)) - thread.start() - kwargs = { - 'key': 'batch.execute', - 'cancel_url': self.request.route_url('batch.rows', uuid=batch.uuid), - 'cancel_msg': "Batch execution was cancelled.", - } - return render_to_response('/progress.mako', kwargs, request=self.request) - - -def includeme(config): - - config.add_route('batches', '/batches') - config.add_view(BatchesGrid, route_name='batches', - renderer='/batches/index.mako', - permission='batches.list') - - config.add_route('batch', '/batches/{uuid}') - config.add_view(BatchCrud, attr='read', route_name='batch', - renderer='/batches/read.mako', - permission='batches.read') - - config.add_route('batch.update', '/batches/{uuid}/edit') - config.add_view(BatchCrud, attr='update', route_name='batch.update', - renderer='/batches/crud.mako', - permission='batches.update') - - config.add_route('batch.delete', '/batches/{uuid}/delete') - config.add_view(BatchCrud, attr='delete', route_name='batch.delete', - permission='batches.delete') - - config.add_route('batch.execute', '/batches/{uuid}/execute') - config.add_view(ExecuteBatch, route_name='batch.execute', - permission='batches.execute') diff --git a/rattail/pyramid/views/batches/params/__init__.py b/rattail/pyramid/views/batches/params/__init__.py deleted file mode 100644 index 213a853e..00000000 --- a/rattail/pyramid/views/batches/params/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.batches.params`` -- Batch Parameter Views -""" - -from edbob.pyramid.views import View - - -__all__ = ['BatchParamsView'] - - -class BatchParamsView(View): - - provider_name = None - - def render_kwargs(self): - return {} - - def __call__(self): - if self.request.POST: - if self.set_batch_params(): - return HTTPFound(location=self.request.get_referer()) - kwargs = self.render_kwargs() - kwargs['provider'] = self.provider_name - return kwargs - - -def includeme(config): - config.include('rattail.pyramid.views.batches.params.labels') diff --git a/rattail/pyramid/views/batches/params/labels.py b/rattail/pyramid/views/batches/params/labels.py deleted file mode 100644 index 73a16d3b..00000000 --- a/rattail/pyramid/views/batches/params/labels.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.batches.params.printlabels`` -- Print Labels Batch -""" - -from edbob.pyramid import Session - -import rattail -from rattail.pyramid.views.batches.params import BatchParamsView - - -class PrintLabels(BatchParamsView): - - provider_name = 'print_labels' - - def render_kwargs(self): - q = Session.query(rattail.LabelProfile) - q = q.order_by(rattail.LabelProfile.ordinal) - profiles = [(x.code, x.description) for x in q] - return {'label_profiles': profiles} - - -def includeme(config): - - config.add_route('batch_params.print_labels', '/batches/params/print-labels') - config.add_view(PrintLabels, route_name='batch_params.print_labels', - renderer='/batches/params/print_labels.mako', - permission='batches.print_labels') diff --git a/rattail/pyramid/views/batches/rows.py b/rattail/pyramid/views/batches/rows.py deleted file mode 100644 index 9c389767..00000000 --- a/rattail/pyramid/views/batches/rows.py +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.batches.rows`` -- Batch Row Views -""" - -from pyramid.httpexceptions import HTTPFound - -from edbob.pyramid import Session -from edbob.pyramid.views import SearchableAlchemyGridView, CrudView - -import rattail -from rattail.pyramid.forms import GPCFieldRenderer - - -def field_with_renderer(field, column): - - if column.sil_name == 'F01': # UPC - field = field.with_renderer(GPCFieldRenderer) - - elif column.sil_name == 'F95': # Shelf Tag Type - q = Session.query(rattail.LabelProfile) - q = q.order_by(rattail.LabelProfile.ordinal) - field = field.dropdown(options=[(x.description, x.code) for x in q]) - - return field - - -def BatchRowsGrid(request): - uuid = request.matchdict['uuid'] - batch = Session.query(rattail.Batch).get(uuid) if uuid else None - if not batch: - return HTTPFound(location=request.route_url('batches')) - - class BatchRowsGrid(SearchableAlchemyGridView): - - mapped_class = batch.rowclass - config_prefix = 'batch.%s' % batch.uuid - sort = 'ordinal' - - def filter_map(self): - fmap = self.make_filter_map() - for column in batch.columns: - if column.visible: - if column.data_type.startswith('CHAR'): - fmap[column.name] = self.filter_ilike( - getattr(batch.rowclass, column.name)) - else: - fmap[column.name] = self.filter_exact( - getattr(batch.rowclass, column.name)) - return fmap - - def filter_config(self): - config = self.make_filter_config() - for column in batch.columns: - if column.visible: - config['filter_label_%s' % column.name] = column.display_name - return config - - def grid(self): - g = self.make_grid() - - include = [g.ordinal.label("Row")] - for column in batch.columns: - if column.visible: - field = getattr(g, column.name) - field = field_with_renderer(field, column) - field = field.label(column.display_name) - include.append(field) - g.column_titles[field.key] = '%s - %s - %s' % ( - column.sil_name, column.description, column.data_type) - - g.configure(include=include, readonly=True) - - route_kwargs = lambda x: {'batch_uuid': x.batch.uuid, 'uuid': x.uuid} - - if self.request.has_perm('batch_rows.read'): - g.clickable = True - g.click_route_name = 'batch_row.read' - g.click_route_kwargs = route_kwargs - - if self.request.has_perm('batch_rows.update'): - g.editable = True - g.edit_route_name = 'batch_row.update' - g.edit_route_kwargs = route_kwargs - - if self.request.has_perm('batch_rows.delete'): - g.deletable = True - g.delete_route_name = 'batch_row.delete' - g.delete_route_kwargs = route_kwargs - - return g - - def render_kwargs(self): - return {'batch': batch} - - grid = BatchRowsGrid(request) - grid.batch = batch - return grid - - -def batch_rows_grid(request): - grid = BatchRowsGrid(request) - return grid() - - -def batch_rows_delete(request): - grid = BatchRowsGrid(request) - grid._filter_config = grid.filter_config() - rows = grid.make_query() - count = rows.count() - rows.delete(synchronize_session=False) - grid.batch.rowcount -= count - request.session.flash("Deleted %d rows from batch." % count) - return HTTPFound(location=request.route_url('batch.rows', uuid=grid.batch.uuid)) - - -def batch_row_crud(request, attr): - batch_uuid = request.matchdict['batch_uuid'] - batch = Session.query(rattail.Batch).get(batch_uuid) - if not batch: - return HTTPFound(location=request.route_url('batches')) - - row_uuid = request.matchdict['uuid'] - row = Session.query(batch.rowclass).get(row_uuid) - if not row: - return HTTPFound(location=request.route_url('batch', uuid=batch.uuid)) - - class BatchRowCrud(CrudView): - - mapped_class = batch.rowclass - pretty_name = "Batch Row" - - @property - def home_url(self): - return self.request.route_url('batch.rows', uuid=batch.uuid) - - @property - def cancel_url(self): - return self.home_url - - def fieldset(self, model): - fs = self.make_fieldset(model) - - include = [fs.ordinal.label("Row Number").readonly()] - for column in batch.columns: - field = getattr(fs, column.name) - field = field_with_renderer(field, column) - field = field.label(column.display_name) - include.append(field) - - fs.configure(include=include) - return fs - - def flash_delete(self, row): - self.request.session.flash("Batch Row %d has been deleted." - % row.ordinal) - - def post_delete(self, model): - batch.rowcount -= 1 - - crud = BatchRowCrud(request) - return getattr(crud, attr)() - -def batch_row_read(request): - return batch_row_crud(request, 'read') - -def batch_row_update(request): - return batch_row_crud(request, 'update') - -def batch_row_delete(request): - return batch_row_crud(request, 'delete') - - -def includeme(config): - - config.add_route('batch.rows', '/batches/{uuid}/rows') - config.add_view(batch_rows_grid, route_name='batch.rows', - renderer='/batches/rows/index.mako', - permission='batches.read') - - config.add_route('batch.rows.delete', '/batches/{uuid}/rows/delete') - config.add_view(batch_rows_delete, route_name='batch.rows.delete', - permission='batch_rows.delete') - - config.add_route('batch_row.read', '/batches/{batch_uuid}/{uuid}') - config.add_view(batch_row_read, route_name='batch_row.read', - renderer='/batches/rows/crud.mako', - permission='batch_rows.read') - - config.add_route('batch_row.update', '/batches/{batch_uuid}/{uuid}/edit') - config.add_view(batch_row_update, route_name='batch_row.update', - renderer='/batches/rows/crud.mako', - permission='batch_rows.update') - - config.add_route('batch_row.delete', '/batches/{batch_uuid}/{uuid}/delete') - config.add_view(batch_row_delete, route_name='batch_row.delete', - permission='batch_rows.delete') diff --git a/rattail/pyramid/views/brands.py b/rattail/pyramid/views/brands.py deleted file mode 100644 index 247fe15b..00000000 --- a/rattail/pyramid/views/brands.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.brands`` -- Brand Views -""" - -from edbob.pyramid.views import SearchableAlchemyGridView, CrudView - -import rattail - - -class BrandsGrid(SearchableAlchemyGridView): - - mapped_class = rattail.Brand - config_prefix = 'brands' - sort = 'name' - - def filter_map(self): - return self.make_filter_map(ilike=['name']) - - def filter_config(self): - return self.make_filter_config( - include_filter_name=True, - filter_type_name='lk') - - def sort_map(self): - return self.make_sort_map('name') - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.name, - ], - readonly=True) - if self.request.has_perm('brands.read'): - g.clickable = True - g.click_route_name = 'brand.read' - if self.request.has_perm('brands.update'): - g.editable = True - g.edit_route_name = 'brand.update' - if self.request.has_perm('brands.delete'): - g.deletable = True - g.delete_route_name = 'brand.delete' - return g - - -class BrandCrud(CrudView): - - mapped_class = rattail.Brand - home_route = 'brands' - - def fieldset(self, model): - fs = self.make_fieldset(model) - fs.configure( - include=[ - fs.name, - ]) - return fs - - -def includeme(config): - - config.add_route('brands', '/brands') - config.add_view(BrandsGrid, route_name='brands', - renderer='/brands/index.mako', - permission='brands.list') - - config.add_route('brand.create', '/brands/new') - config.add_view(BrandCrud, attr='create', route_name='brand.create', - renderer='/brands/crud.mako', - permission='brands.create') - - config.add_route('brand.read', '/brands/{uuid}') - config.add_view(BrandCrud, attr='read', route_name='brand.read', - renderer='/brands/crud.mako', - permission='brands.read') - - config.add_route('brand.update', '/brands/{uuid}/edit') - config.add_view(BrandCrud, attr='update', route_name='brand.update', - renderer='/brands/crud.mako', - permission='brands.update') - - config.add_route('brand.delete', '/brands/{uuid}/delete') - config.add_view(BrandCrud, attr='delete', route_name='brand.delete', - permission='brands.delete') diff --git a/rattail/pyramid/views/categories.py b/rattail/pyramid/views/categories.py deleted file mode 100644 index 51c6b6ec..00000000 --- a/rattail/pyramid/views/categories.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.categories`` -- Category Views -""" - -from edbob.pyramid.views import GridView -from edbob.pyramid.views.crud import Crud - -import rattail - - -class CategoryGrid(GridView): - - mapped_class = rattail.Category - route_name = 'categories.list' - route_prefix = 'category' - - def filter_map(self): - return self.make_filter_map( - exact=['number'], - ilike=['name']) - - def search_config(self, fmap): - return self.make_search_config( - fmap, - include_filter_name=True, - filter_type_name='lk') - - def grid_config(self, search, fmap): - return self.make_grid_config(search, fmap, - sort='number') - - def sort_map(self): - return self.make_sort_map('number', 'name') - - def grid(self, data, config): - g = self.make_grid(data, config) - g.configure( - include=[ - g.number, - g.name, - ], - readonly=True) - return g - - -class CategoryCrud(Crud): - - mapped_class = rattail.Category - home_route = 'categories.list' - url_prefix = '/categories' - - def fieldset(self, obj): - fs = self.make_fieldset(obj) - fs.configure( - include=[ - fs.number, - fs.name, - ]) - return fs - - -def includeme(config): - CategoryGrid.add_route(config, 'categories.list', '/categories') - CategoryCrud.add_routes(config) diff --git a/rattail/pyramid/views/customer_groups.py b/rattail/pyramid/views/customer_groups.py deleted file mode 100644 index 83056dcc..00000000 --- a/rattail/pyramid/views/customer_groups.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.customergroups`` -- CustomerGroup Views -""" - -from edbob.pyramid.views import SearchableAlchemyGridView, CrudView - -import rattail - - -class CustomerGroupsGrid(SearchableAlchemyGridView): - - mapped_class = rattail.CustomerGroup - config_prefix = 'customer_groups' - sort = 'name' - - def filter_map(self): - return self.make_filter_map(ilike=['name']) - - def filter_config(self): - return self.make_filter_config( - include_filter_name=True, - filter_type_name='lk') - - def sort_map(self): - return self.make_sort_map('id', 'name') - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.id.label("ID"), - g.name, - ], - readonly=True) - return g - - -class CustomerGroupCrud(CrudView): - - mapped_class = rattail.CustomerGroup - home_route = 'customer_groups' - pretty_name = "Customer Group" - - def fieldset(self, model): - fs = self.make_fieldset(model) - fs.configure( - include=[ - fs.id.label("ID"), - fs.name, - ]) - return fs - - -def includeme(config): - - config.add_route('customer_groups', '/customer-groups') - config.add_view(CustomerGroupsGrid, route_name='customer_groups', - renderer='/customer_groups/index.mako', - permission='customer_groups.list') - - config.add_route('customer_group.read', '/customer-groups/{uuid}') - config.add_view(CustomerGroupCrud, attr='read', route_name='customer_group.read', - renderer='/customer_groups/crud.mako', - permission='customer_groups.read') diff --git a/rattail/pyramid/views/customers.py b/rattail/pyramid/views/customers.py deleted file mode 100644 index c02344a4..00000000 --- a/rattail/pyramid/views/customers.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.customers`` -- Customer Views -""" - -from sqlalchemy import and_ - -import edbob -from edbob.pyramid.views import SearchableAlchemyGridView, CrudView -from edbob.pyramid.forms import EnumFieldRenderer - -import rattail - - -class CustomersGrid(SearchableAlchemyGridView): - - mapped_class = rattail.Customer - config_prefix = 'customers' - sort = 'name' - clickable = True - - def join_map(self): - return { - 'email': - lambda q: q.outerjoin(rattail.CustomerEmailAddress, and_( - rattail.CustomerEmailAddress.parent_uuid == rattail.Customer.uuid, - rattail.CustomerEmailAddress.preference == 1)), - 'phone': - lambda q: q.outerjoin(rattail.CustomerPhoneNumber, and_( - rattail.CustomerPhoneNumber.parent_uuid == rattail.Customer.uuid, - rattail.CustomerPhoneNumber.preference == 1)), - } - - def filter_map(self): - return self.make_filter_map( - exact=['id'], - ilike=['name'], - email=self.filter_ilike(rattail.CustomerEmailAddress.address), - phone=self.filter_ilike(rattail.CustomerPhoneNumber.number)) - - def filter_config(self): - return self.make_filter_config( - include_filter_name=True, - filter_type_name='lk', - filter_label_phone="Phone Number", - filter_label_email="Email Address", - filter_label_id="ID") - - def sort_map(self): - return self.make_sort_map( - 'id', 'name', - email=self.sorter(rattail.CustomerEmailAddress.address), - phone=self.sorter(rattail.CustomerPhoneNumber.number)) - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.id.label("ID"), - g.name, - g.phone.label("Phone Number"), - g.email.label("Email Address"), - ], - readonly=True) - g.click_route_name = 'customer.read' - return g - - -class CustomerCrud(CrudView): - - mapped_class = rattail.Customer - home_route = 'customers' - - def fieldset(self, model): - fs = self.make_fieldset(model) - fs.email_preference.set(renderer=EnumFieldRenderer(edbob.EMAIL_PREFERENCE)) - fs.configure( - include=[ - fs.id.label("ID"), - fs.name, - fs.phone.label("Phone Number"), - fs.email.label("Email Address"), - fs.email_preference, - ]) - return fs - - -def includeme(config): - - config.add_route('customers', '/customers') - config.add_view(CustomersGrid, route_name='customers', - renderer='/customers/index.mako', - permission='customers.list') - - config.add_route('customer.read', '/customers/{uuid}') - config.add_view(CustomerCrud, attr='read', route_name='customer.read', - renderer='/customers/read.mako', - permission='customers.read') diff --git a/rattail/pyramid/views/departments.py b/rattail/pyramid/views/departments.py deleted file mode 100644 index 4a7ba673..00000000 --- a/rattail/pyramid/views/departments.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.departments`` -- Department Views -""" - - -from edbob.pyramid.views import SearchableAlchemyGridView, AlchemyGridView - -import rattail - - -class DepartmentsGrid(SearchableAlchemyGridView): - - mapped_class = rattail.Department - config_prefix = 'departments' - sort = 'name' - - def filter_map(self): - return self.make_filter_map(ilike=['name']) - - def filter_config(self): - return self.make_filter_config( - include_filter_name=True, - filter_type_name='lk') - - def sort_map(self): - return self.make_sort_map('number', 'name') - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.number, - g.name, - ], - readonly=True) - return g - - -class DepartmentsByVendorGrid(AlchemyGridView): - - mapped_class = rattail.Department - config_prefix = 'departments.by_vendor' - checkboxes = True - partial_only = True - - def query(self): - q = self.make_query() - q = q.outerjoin(rattail.Product) - q = q.join(rattail.ProductCost) - q = q.join(rattail.Vendor) - q = q.filter(rattail.Vendor.uuid == self.request.params['uuid']) - q = q.distinct() - q = q.order_by(rattail.Department.name) - return q - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.name, - ], - readonly=True) - return g - - -def includeme(config): - - config.add_route('departments', '/departments') - config.add_view(DepartmentsGrid, route_name='departments', - renderer='/departments/index.mako', - permission='departments.list') - - config.add_route('departments.by_vendor', '/departments/by-vendor') - config.add_view(DepartmentsByVendorGrid, route_name='departments.by_vendor', - permission='departments.list') diff --git a/rattail/pyramid/views/employees.py b/rattail/pyramid/views/employees.py deleted file mode 100644 index aedd9c6b..00000000 --- a/rattail/pyramid/views/employees.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.employees`` -- Employee Views -""" - -from sqlalchemy import and_ - -import edbob -from edbob.pyramid.forms import AssociationProxyField -from edbob.pyramid.views import SearchableAlchemyGridView - -import rattail - - -class EmployeesGrid(SearchableAlchemyGridView): - - mapped_class = rattail.Employee - config_prefix = 'employees' - sort = 'first_name' - - def join_map(self): - return { - 'phone': - lambda q: q.outerjoin(rattail.EmployeePhoneNumber, and_( - rattail.EmployeePhoneNumber.parent_uuid == rattail.Employee.uuid, - rattail.EmployeePhoneNumber.preference == 1)), - } - - def filter_map(self): - return self.make_filter_map( - first_name=self.filter_ilike(edbob.Person.first_name), - last_name=self.filter_ilike(edbob.Person.last_name), - phone=self.filter_ilike(rattail.EmployeePhoneNumber.number)) - - def filter_config(self): - return self.make_filter_config( - include_filter_first_name=True, - filter_type_first_name='lk', - include_filter_last_name=True, - filter_type_last_name='lk', - filter_label_phone="Phone Number") - - def sort_map(self): - return self.make_sort_map( - first_name=self.sorter(edbob.Person.first_name), - last_name=self.sorter(edbob.Person.last_name), - phone=self.sorter(rattail.EmployeePhoneNumber.number)) - - def query(self): - q = self.make_query() - q = q.join(edbob.Person) - if not self.request.has_perm('employees.edit'): - q = q.filter(rattail.Employee.status == rattail.EMPLOYEE_STATUS_CURRENT) - return q - - def grid(self): - g = self.make_grid() - g.append(AssociationProxyField('first_name')) - g.append(AssociationProxyField('last_name')) - g.configure( - include=[ - g.first_name, - g.last_name, - g.phone.label("Phone Number"), - ], - readonly=True) - return g - - -def includeme(config): - - config.add_route('employees', '/employees') - config.add_view(EmployeesGrid, route_name='employees', - renderer='/employees/index.mako', - permission='employees.list') diff --git a/rattail/pyramid/views/labels.py b/rattail/pyramid/views/labels.py deleted file mode 100644 index 9ff7849c..00000000 --- a/rattail/pyramid/views/labels.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.labels`` -- Label Views -""" - -from pyramid.httpexceptions import HTTPFound - -import formalchemy - -from webhelpers.html import HTML - -from edbob.pyramid import Session -from edbob.pyramid.views import SearchableAlchemyGridView, CrudView - -import rattail - - -class ProfilesGrid(SearchableAlchemyGridView): - - mapped_class = rattail.LabelProfile - config_prefix = 'label_profiles' - sort = 'ordinal' - - def filter_map(self): - return self.make_filter_map( - exact=['code'], - ilike=['description']) - - def sort_map(self): - return self.make_sort_map('ordinal', 'code', 'description') - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.ordinal, - g.code, - g.description, - ], - readonly=True) - if self.request.has_perm('label_profiles.read'): - g.clickable = True - g.click_route_name = 'label_profile.read' - if self.request.has_perm('label_profiles.update'): - g.editable = True - g.edit_route_name = 'label_profile.update' - if self.request.has_perm('label_profiles.delete'): - g.deletable = True - g.delete_route_name = 'label_profile.delete' - return g - - -class ProfileCrud(CrudView): - - mapped_class = rattail.LabelProfile - home_route = 'label_profiles' - pretty_name = "Label Profile" - update_cancel_route = 'label_profile.read' - - def fieldset(self, model): - - class FormatFieldRenderer(formalchemy.TextAreaFieldRenderer): - - def render_readonly(self, **kwargs): - value = self.raw_value - if not value: - return '' - return HTML.tag('pre', c=value) - - def render(self, **kwargs): - kwargs.setdefault('size', (80, 8)) - return super(FormatFieldRenderer, self).render(**kwargs) - - fs = self.make_fieldset(model) - fs.format.set(renderer=FormatFieldRenderer) - fs.configure( - include=[ - fs.ordinal, - fs.code, - fs.description, - fs.printer_spec, - fs.formatter_spec, - fs.format, - ]) - return fs - - def post_save_url(self, form): - return self.request.route_url('label_profile.read', - uuid=form.fieldset.model.uuid) - - -def printer_settings(request): - uuid = request.matchdict['uuid'] - profile = Session.query(rattail.LabelProfile).get(uuid) if uuid else None - if not profile: - return HTTPFound(location=request.route_url('label_profiles')) - - read_profile = HTTPFound(location=request.route_url( - 'label_profile.read', uuid=profile.uuid)) - - printer = profile.get_printer() - if not printer: - request.session.flash("Label profile \"%s\" does not have a functional " - "printer spec." % profile) - return read_profile - if not printer.required_settings: - request.session.flash("Printer class for label profile \"%s\" does not " - "require any settings." % profile) - return read_profile - - if request.POST: - for setting in printer.required_settings: - if setting in request.POST: - profile.save_printer_setting(setting, request.POST[setting]) - return read_profile - - return {'profile': profile, 'printer': printer} - - -def includeme(config): - - config.add_route('label_profiles', '/labels/profiles') - config.add_view(ProfilesGrid, route_name='label_profiles', - renderer='/labels/profiles/index.mako', - permission='label_profiles.list') - - config.add_route('label_profile.create', '/labels/profiles/new') - config.add_view(ProfileCrud, attr='create', route_name='label_profile.create', - renderer='/labels/profiles/crud.mako', - permission='label_profiles.create') - - config.add_route('label_profile.read', '/labels/profiles/{uuid}') - config.add_view(ProfileCrud, attr='read', route_name='label_profile.read', - renderer='/labels/profiles/read.mako', - permission='label_profiles.read') - - config.add_route('label_profile.update', '/labels/profiles/{uuid}/edit') - config.add_view(ProfileCrud, attr='update', route_name='label_profile.update', - renderer='/labels/profiles/crud.mako', - permission='label_profiles.update') - - config.add_route('label_profile.delete', '/labels/profiles/{uuid}/delete') - config.add_view(ProfileCrud, attr='delete', route_name='label_profile.delete', - permission='label_profiles.delete') - - config.add_route('label_profile.printer_settings', '/labels/profiles/{uuid}/printer') - config.add_view(printer_settings, route_name='label_profile.printer_settings', - renderer='/labels/profiles/printer.mako', - permission='label_profiles.update') diff --git a/rattail/pyramid/views/products.py b/rattail/pyramid/views/products.py deleted file mode 100644 index a39802b2..00000000 --- a/rattail/pyramid/views/products.py +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.products`` -- Product Views -""" - -import threading - -from sqlalchemy import and_ -from sqlalchemy.orm import joinedload - -from webhelpers.html.tags import link_to - -from pyramid.httpexceptions import HTTPFound -from pyramid.renderers import render_to_response - -import edbob -from edbob.pyramid import Session -from edbob.pyramid.progress import SessionProgress -from edbob.pyramid.views import SearchableAlchemyGridView, CrudView - -import rattail -import rattail.labels -from rattail import sil -from rattail import batches -from rattail.exceptions import LabelPrintingError -from rattail.pyramid.forms import GPCFieldRenderer, PriceFieldRenderer - - -class ProductsGrid(SearchableAlchemyGridView): - - mapped_class = rattail.Product - config_prefix = 'products' - sort = 'description' - clickable = True - - def join_map(self): - - def join_vendor(q): - q = q.outerjoin( - rattail.ProductCost, - and_( - rattail.ProductCost.product_uuid == rattail.Product.uuid, - rattail.ProductCost.preference == 1, - )) - q = q.outerjoin(rattail.Vendor) - return q - - return { - 'brand': - lambda q: q.outerjoin(rattail.Brand), - 'department': - lambda q: q.outerjoin(rattail.Department, - rattail.Department.uuid == rattail.Product.department_uuid), - 'subdepartment': - lambda q: q.outerjoin(rattail.Subdepartment, - rattail.Subdepartment.uuid == rattail.Product.subdepartment_uuid), - 'regular_price': - lambda q: q.outerjoin(rattail.ProductPrice, - rattail.ProductPrice.uuid == rattail.Product.regular_price_uuid), - 'current_price': - lambda q: q.outerjoin(rattail.ProductPrice, - rattail.ProductPrice.uuid == rattail.Product.current_price_uuid), - 'vendor': - join_vendor, - } - - def filter_map(self): - return self.make_filter_map( - exact=['upc'], - ilike=['description', 'size'], - brand=self.filter_ilike(rattail.Brand.name), - department=self.filter_ilike(rattail.Department.name), - subdepartment=self.filter_ilike(rattail.Subdepartment.name), - vendor=self.filter_ilike(rattail.Vendor.name)) - - def filter_config(self): - return self.make_filter_config( - include_filter_upc=True, - filter_type_upc='eq', - filter_label_upc="UPC", - include_filter_brand=True, - filter_type_brand='lk', - include_filter_description=True, - filter_type_description='lk', - include_filter_department=True, - filter_type_department='lk', - include_filter_vendor=True, - filter_type_vendor='lk') - - def sort_map(self): - return self.make_sort_map( - 'upc', 'description', 'size', - brand=self.sorter(rattail.Brand.name), - department=self.sorter(rattail.Department.name), - subdepartment=self.sorter(rattail.Subdepartment.name), - regular_price=self.sorter(rattail.ProductPrice.price), - current_price=self.sorter(rattail.ProductPrice.price), - vendor=self.sorter(rattail.Vendor.name)) - - def query(self): - q = self.make_query() - q = q.options(joinedload(rattail.Product.brand)) - q = q.options(joinedload(rattail.Product.department)) - q = q.options(joinedload(rattail.Product.subdepartment)) - q = q.options(joinedload(rattail.Product.regular_price)) - q = q.options(joinedload(rattail.Product.current_price)) - q = q.options(joinedload(rattail.Product.vendor)) - return q - - def grid(self): - g = self.make_grid() - g.upc.set(renderer=GPCFieldRenderer) - g.regular_price.set(renderer=PriceFieldRenderer) - g.current_price.set(renderer=PriceFieldRenderer) - g.configure( - include=[ - g.upc.label("UPC"), - g.brand, - g.description, - g.size, - g.subdepartment, - g.vendor, - g.regular_price.label("Reg. Price"), - g.current_price.label("Cur. Price"), - ], - readonly=True) - - g.click_route_name = 'product.read' - - q = Session.query(rattail.LabelProfile) - if q.count(): - def labels(row): - return link_to("Print", '#', class_='print-label') - g.add_column('labels', "Labels", labels) - - return g - - def render_kwargs(self): - q = Session.query(rattail.LabelProfile) - q = q.order_by(rattail.LabelProfile.ordinal) - return {'label_profiles': q.all()} - - -class ProductCrud(CrudView): - - mapped_class = rattail.Product - home_route = 'products' - - def fieldset(self, model): - fs = self.make_fieldset(model) - fs.upc.set(renderer=GPCFieldRenderer) - fs.regular_price.set(renderer=PriceFieldRenderer) - fs.current_price.set(renderer=PriceFieldRenderer) - fs.configure( - include=[ - fs.upc.label("UPC"), - fs.brand, - fs.description, - fs.size, - fs.department, - fs.subdepartment, - fs.regular_price, - fs.current_price, - ]) - return fs - - -def print_labels(request): - profile = request.params.get('profile') - profile = Session.query(rattail.LabelProfile).get(profile) if profile else None - if not profile: - return {'error': "Label profile not found"} - - product = request.params.get('product') - product = Session.query(rattail.Product).get(product) if product else None - if not product: - return {'error': "Product not found"} - - quantity = request.params.get('quantity') - if not quantity.isdigit(): - return {'error': "Quantity must be numeric"} - quantity = int(quantity) - - printer = profile.get_printer() - if not printer: - return {'error': "Couldn't get printer from label profile"} - - try: - printer.print_labels([(product, quantity)]) - except LabelPrintingError, error: - return {'error': str(error)} - return {} - - -class CreateProductsBatch(ProductsGrid): - - def make_batch(self, provider): - session = edbob.Session() - - self._filter_config = self.filter_config() - self._sort_config = self.sort_config() - products = self.make_query(session) - - progress = SessionProgress(self.request.session, 'products.batch') - batch = provider.make_batch(session, products, progress) - if not batch: - session.rollback() - session.close() - return - - session.commit() - session.refresh(batch) - session.close() - - progress.session.load() - progress.session['success_url'] = self.request.route_url('batch', uuid=batch.uuid) - progress.session['success_msg'] = "Batch \"%s\" has been created." % batch.description - progress.session.save() - - def __call__(self): - if self.request.POST: - provider = self.request.POST.get('provider') - if provider: - provider = batches.get_provider(provider) - if provider: - - if self.request.POST.get('params') == 'True': - provider.set_params(Session(), **self.request.POST) - - else: - try: - url = self.request.route_url('batch_params.%s' % provider.name) - except KeyError: - pass - else: - self.request.session['referer'] = self.request.current_route_url() - return HTTPFound(location=url) - - thread = threading.Thread(target=self.make_batch, args=(provider,)) - thread.start() - kwargs = { - 'key': 'products.batch', - 'cancel_url': self.request.route_url('products'), - 'cancel_msg': "Batch creation was cancelled.", - } - return render_to_response('/progress.mako', kwargs, request=self.request) - - providers = [(x.name, x.description) for x in batches.iter_providers()] - return {'providers': providers} - - -def includeme(config): - - config.add_route('products', '/products') - config.add_view(ProductsGrid, route_name='products', - renderer='/products/index.mako', - permission='products.list') - - config.add_route('products.print_labels', '/products/labels') - config.add_view(print_labels, route_name='products.print_labels', - renderer='json', permission='products.print_labels') - - config.add_route('products.create_batch', '/products/batch') - config.add_view(CreateProductsBatch, route_name='products.create_batch', - renderer='/products/batch.mako', - permission='batches.create') - - config.add_route('product.read', '/products/{uuid}') - config.add_view(ProductCrud, attr='read', route_name='product.read', - renderer='/products/read.mako', - permission='products.read') diff --git a/rattail/pyramid/views/reports.py b/rattail/pyramid/views/reports.py deleted file mode 100644 index 8556e874..00000000 --- a/rattail/pyramid/views/reports.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python - -""" -``dtail.views.reports`` -- Report Views -""" - -import os -import os.path -import re - -from mako.template import Template - -from pyramid.response import Response - -import edbob -from edbob.pyramid import Session - -import rattail - - -def ordering_report(request): - """ - This is the "Ordering Worksheet" report. - """ - - if request.params.get('vendor'): - vendor = Session.query(rattail.Vendor).get(request.params['vendor']) - if vendor: - departments = [] - uuids = request.params.get('departments') - if uuids: - for uuid in uuids.split(','): - dept = Session.query(rattail.Department).get(uuid) - if dept: - departments.append(dept) - body = write_ordering_worksheet(vendor, departments) - response = Response(content_type='text/html') - response.headers['Content-Length'] = len(body) - response.headers['Content-Disposition'] = 'attachment; filename=ordering.html' - response.body = body - return response - return {} - - -def write_ordering_worksheet(vendor, departments): - """ - Rendering engine for the ordering worksheet report. - """ - - q = Session.query(rattail.ProductCost) - q = q.join(rattail.Product) - q = q.filter(rattail.ProductCost.vendor == vendor) - q = q.filter(rattail.Product.department_uuid.in_([x.uuid for x in departments])) - - costs = {} - for cost in q: - dept = cost.product.department - subdept = cost.product.subdepartment - costs.setdefault(dept, {}) - costs[dept].setdefault(subdept, []) - costs[dept][subdept].append(cost) - - plu_upc_pattern = re.compile(r'^0000000(\d{5})$') - weighted_upc_pattern = re.compile(r'^02(\d{5})00000$') - - def get_upc(prod): - upc = '%012u' % prod.upc - m = plu_upc_pattern.match(upc) - if m: - return str(int(m.group(1))) - m = weighted_upc_pattern.match(upc) - if m: - return str(int(m.group(1))) - return upc - - now = edbob.local_time() - data = dict( - vendor=vendor, - costs=costs, - date=now.strftime('%a %d %b %Y'), - time=now.strftime('%I:%M %p'), - get_upc=get_upc, - rattail=rattail, - ) - - report = os.path.join(os.path.dirname(__file__), os.pardir, 'reports', 'ordering_worksheet.mako') - report = os.path.abspath(report) - template = Template(filename=report, disable_unicode=True) - return template.render(**data) - - -def includeme(config): - config.add_route('reports.ordering', '/reports/ordering') - config.add_view(ordering_report, route_name='reports.ordering', renderer='/reports/ordering.mako') diff --git a/rattail/pyramid/views/stores.py b/rattail/pyramid/views/stores.py deleted file mode 100644 index 476acadd..00000000 --- a/rattail/pyramid/views/stores.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.stores`` -- Store Views -""" - -from sqlalchemy import and_ - -from edbob.pyramid.views import SearchableAlchemyGridView, CrudView - -import rattail - - -class StoresGrid(SearchableAlchemyGridView): - - mapped_class = rattail.Store - config_prefix = 'stores' - sort = 'name' - - def join_map(self): - return { - 'email': - lambda q: q.outerjoin(rattail.StoreEmailAddress, and_( - rattail.StoreEmailAddress.parent_uuid == rattail.Store.uuid, - rattail.StoreEmailAddress.preference == 1)), - 'phone': - lambda q: q.outerjoin(rattail.StorePhoneNumber, and_( - rattail.StorePhoneNumber.parent_uuid == rattail.Store.uuid, - rattail.StorePhoneNumber.preference == 1)), - } - - def filter_map(self): - return self.make_filter_map( - exact=['id'], - ilike=['name'], - email=self.filter_ilike(rattail.StoreEmailAddress.address), - phone=self.filter_ilike(rattail.StorePhoneNumber.number)) - - def filter_config(self): - return self.make_filter_config( - include_filter_name=True, - filter_type_name='lk', - filter_label_id="ID") - - def sort_map(self): - return self.make_sort_map( - 'id', 'name', - email=self.sorter(rattail.StoreEmailAddress.address), - phone=self.sorter(rattail.StorePhoneNumber.number)) - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.id.label("ID"), - g.name, - g.phone.label("Phone Number"), - g.email.label("Email Address"), - ], - readonly=True) - g.clickable = True - g.click_route_name = 'store.read' - if self.request.has_perm('stores.update'): - g.editable = True - g.edit_route_name = 'store.update' - if self.request.has_perm('stores.delete'): - g.deletable = True - g.delete_route_name = 'store.delete' - return g - - -class StoreCrud(CrudView): - - mapped_class = rattail.Store - home_route = 'stores' - - def fieldset(self, model): - fs = self.make_fieldset(model) - fs.configure( - include=[ - fs.id.label("ID"), - fs.name, - fs.phone.label("Phone Number").readonly(), - fs.email.label("Email Address").readonly(), - ]) - return fs - - -def includeme(config): - - config.add_route('stores', '/stores') - config.add_view(StoresGrid, route_name='stores', - renderer='/stores/index.mako', - permission='stores.list') - - config.add_route('store.create', '/stores/new') - config.add_view(StoreCrud, attr='create', route_name='store.create', - renderer='/stores/crud.mako', - permission='stores.create') - - config.add_route('store.read', '/stores/{uuid}') - config.add_view(StoreCrud, attr='read', route_name='store.read', - renderer='/stores/crud.mako', - permission='stores.read') - - config.add_route('store.update', '/stores/{uuid}/edit') - config.add_view(StoreCrud, attr='update', route_name='store.update', - renderer='/stores/crud.mako', - permission='stores.update') - - config.add_route('store.delete', '/stores/{uuid}/delete') - config.add_view(StoreCrud, attr='delete', route_name='store.delete', - permission='stores.delete') diff --git a/rattail/pyramid/views/subdepartments.py b/rattail/pyramid/views/subdepartments.py deleted file mode 100644 index 1d6e4b67..00000000 --- a/rattail/pyramid/views/subdepartments.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.subdepartments`` -- Subdepartment Views -""" - -from edbob.pyramid.views import SearchableAlchemyGridView - -import rattail - - -class SubdepartmentsGrid(SearchableAlchemyGridView): - - mapped_class = rattail.Subdepartment - config_prefix = 'subdepartments' - sort = 'name' - - def filter_map(self): - return self.make_filter_map(ilike=['name']) - - def filter_config(self): - return self.make_filter_config( - include_filter_name=True, - filter_type_name='lk') - - def sort_map(self): - return self.make_sort_map('number', 'name') - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.number, - g.name, - ], - readonly=True) - return g - - -def includeme(config): - - config.add_route('subdepartments', '/subdepartments') - config.add_view(SubdepartmentsGrid, route_name='subdepartments', - renderer='/subdepartments/index.mako', - permission='subdepartments.list') diff --git a/rattail/pyramid/views/vendors.py b/rattail/pyramid/views/vendors.py deleted file mode 100644 index 2685f398..00000000 --- a/rattail/pyramid/views/vendors.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - -""" -``rattail.pyramid.views.vendors`` -- Vendor Views -""" - -from edbob.pyramid.views import (SearchableAlchemyGridView, CrudView, - AutocompleteView) - -import rattail - - -class VendorsGrid(SearchableAlchemyGridView): - - mapped_class = rattail.Vendor - config_prefix = 'vendors' - sort = 'name' - - def filter_map(self): - return self.make_filter_map(ilike=['name']) - - def filter_config(self): - return self.make_filter_config( - include_filter_name=True, - filter_type_name='lk', - filter_label_id="ID") - - def sort_map(self): - return self.make_sort_map('id', 'name') - - def grid(self): - g = self.make_grid() - g.configure( - include=[ - g.id.label("ID"), - g.name, - g.phone, - g.contact, - ], - readonly=True) - if self.request.has_perm('vendors.read'): - g.clickable = True - g.click_route_name = 'vendor.read' - if self.request.has_perm('vendors.update'): - g.editable = True - g.edit_route_name = 'vendor.update' - if self.request.has_perm('vendors.delete'): - g.deletable = True - g.delete_route_name = 'vendor.delete' - return g - - -class VendorCrud(CrudView): - - mapped_class = rattail.Vendor - home_route = 'vendors' - - def fieldset(self, model): - fs = self.make_fieldset(model) - fs.configure( - include=[ - fs.id.label("ID"), - fs.name, - fs.special_discount, - ]) - return fs - - -class VendorsAutocomplete(AutocompleteView): - - mapped_class = rattail.Vendor - fieldname = 'name' - - -def includeme(config): - - config.add_route('vendors', '/vendors') - config.add_view(VendorsGrid, route_name='vendors', - renderer='/vendors/index.mako', - permission='vendors.list') - - config.add_route('vendors.autocomplete', '/vendors/autocomplete') - config.add_view(VendorsAutocomplete, route_name='vendors.autocomplete', - renderer='json', permission='vendors.list') - - config.add_route('vendor.create', '/vendors/new') - config.add_view(VendorCrud, attr='create', route_name='vendor.create', - renderer='/vendors/crud.mako', - permission='vendors.create') - - config.add_route('vendor.read', '/vendors/{uuid}') - config.add_view(VendorCrud, attr='read', route_name='vendor.read', - renderer='/vendors/crud.mako', - permission='vendors.read') - - config.add_route('vendor.update', '/vendors/{uuid}/edit') - config.add_view(VendorCrud, attr='update', route_name='vendor.update', - renderer='/vendors/crud.mako', - permission='vendors.update') - - config.add_route('vendor.delete', '/vendors/{uuid}/delete') - config.add_view(VendorCrud, attr='delete', route_name='vendor.delete', - permission='vendors.delete') diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 018c3b43..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[egg_info] -tag_build = .dev diff --git a/setup.py b/setup.py deleted file mode 100644 index e3b93ad6..00000000 --- a/setup.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Rattail. If not, see . -# -################################################################################ - - -import os.path -from setuptools import setup, find_packages - - -here = os.path.abspath(os.path.dirname(__file__)) -execfile(os.path.join(here, 'rattail', 'pyramid', '_version.py')) -README = open(os.path.join(here, 'README.txt')).read() -CHANGES = open(os.path.join(here, 'CHANGES.txt')).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 - - 'edbob[db,pyramid]>=0.1a14', # 0.1a15.dev - 'rattail>=0.3a6', # 0.3a7.dev - ] - - -setup( - name = "rattail.pyramid", - version = __version__, - author = "Lance Edgar", - author_email = "lance@edbob.org", - url = "http://rattail.edbob.org/", - license = "GNU Affero GPL v3", - description = "Rattail Pyramid Framework", - long_description = README + '\n\n' + CHANGES, - - classifiers = [ - 'Development Status :: 3 - Alpha', - 'Environment :: Web Environment', - 'Framework :: Pylons', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU Affero General Public License v3', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Office/Business', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - - install_requires = requires, - - namespace_packages = ['rattail'], - packages = find_packages(), - include_package_data = True, - zip_safe = False, - ) diff --git a/tailbone/__init__.py b/tailbone/__init__.py new file mode 100644 index 00000000..fc93539e --- /dev/null +++ b/tailbone/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2017 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ + +""" +Backoffice Web Application for Rattail +""" + +from ._version import __version__ + + +def includeme(config): + config.include('tailbone.static') + config.include('tailbone.subscribers') + config.include('tailbone.views') diff --git a/tailbone/_version.py b/tailbone/_version.py new file mode 100644 index 00000000..7095f6c8 --- /dev/null +++ b/tailbone/_version.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8; -*- + +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + + +__version__ = version('Tailbone') diff --git a/tailbone/api/__init__.py b/tailbone/api/__init__.py new file mode 100644 index 00000000..1fae059f --- /dev/null +++ b/tailbone/api/__init__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API +""" + +from __future__ import unicode_literals, absolute_import + +from .core import APIView, api +from .master import APIMasterView, SortColumn +# TODO: remove this +from .master2 import APIMasterView2 + + +def includeme(config): + config.include('tailbone.api.common') + config.include('tailbone.api.auth') + config.include('tailbone.api.customers') + config.include('tailbone.api.upgrades') + config.include('tailbone.api.users') diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py new file mode 100644 index 00000000..a710e30d --- /dev/null +++ b/tailbone/api/auth.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Auth Views +""" + +from cornice import Service + +from tailbone.api import APIView, api +from tailbone.db import Session +from tailbone.auth import login_user, logout_user + + +class AuthenticationView(APIView): + + @api + def check_session(self): + """ + View to serve as "no-op" / ping action to check current user's session. + This will establish a server-side web session for the user if none + exists. Note that this also resets the user's session timer. + """ + data = {'ok': True, 'permissions': []} + if self.request.user: + data['user'] = self.get_user_info(self.request.user) + data['permissions'] = list(self.request.user_permissions) + + # background color may be set per-request, by some apps + if hasattr(self.request, 'background_color') and self.request.background_color: + data['background_color'] = self.request.background_color + else: # otherwise we use the one from config + data['background_color'] = self.rattail_config.get( + 'tailbone', 'background_color') + + # TODO: this seems the best place to return some global app + # settings, but maybe not desirable in all cases..in which + # case should caller need to ask for these explicitly? or + # make a different call altogether to get them..? + app = self.get_rattail_app() + customer_handler = app.get_clientele_handler() + data['settings'] = { + 'customer_field_dropdown': customer_handler.choice_uses_dropdown(), + } + + return data + + @api + def login(self): + """ + API login view. + """ + if self.request.method == 'OPTIONS': + return self.request.response + + username = self.request.json.get('username') + password = self.request.json.get('password') + if not (username and password): + return {'error': "Invalid username or password"} + + # make sure credentials are valid + user = self.authenticate_user(username, password) + if not user: + return {'error': "Invalid username or password"} + + # is there some reason this user should not login? + error = self.why_cant_user_login(user) + if error: + return {'error': error} + + app = self.get_rattail_app() + auth = app.get_auth_handler() + + login_user(self.request, user) + return { + 'ok': True, + 'user': self.get_user_info(user), + 'permissions': list(auth.get_permissions(Session(), user)), + } + + def authenticate_user(self, username, password): + app = self.get_rattail_app() + auth = app.get_auth_handler() + return auth.authenticate_user(Session(), username, password) + + def why_cant_user_login(self, user): + """ + This method is given a ``User`` instance, which represents someone who + is just now trying to login, and has already cleared the basic hurdle + of providing the correct credentials for a user on file. This method + is responsible then, for further verification that this user *should* + in fact be allowed to login to this app node. If the method determines + a reason the user should *not* be allowed to login, then it should + return that reason as a simple string. + """ + + @api + def logout(self): + """ + API logout view. + """ + if self.request.method == 'OPTIONS': + return self.request.response + + logout_user(self.request) + return {'ok': True} + + @api + def become_root(self): + """ + Elevate the current request to 'root' for full system access. + """ + if not self.request.is_admin: + raise self.forbidden() + self.request.user.record_event(self.enum.USER_EVENT_BECOME_ROOT) + self.request.session['is_root'] = True + return { + 'ok': True, + 'user': self.get_user_info(self.request.user), + } + + @api + def stop_root(self): + """ + Lower the current request from 'root' back to normal access. + """ + if not self.request.is_admin: + raise self.forbidden() + self.request.user.record_event(self.enum.USER_EVENT_STOP_ROOT) + self.request.session['is_root'] = False + return { + 'ok': True, + 'user': self.get_user_info(self.request.user), + } + + @api + def change_password(self): + """ + View which allows a user to change their password. + """ + if self.request.method == 'OPTIONS': + return self.request.response + + if not self.request.user: + raise self.forbidden() + + if self.request.user.prevent_password_change and not self.request.is_root: + raise self.forbidden() + + data = self.request.json_body + + # first make sure "current" password is accurate + if not self.authenticate_user(self.request.user, data['current_password']): + return {'error': "The current/old password you provided is incorrect"} + + # okay then, set new password + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, data['new_password']) + return { + 'ok': True, + 'user': self.get_user_info(self.request.user), + } + + @classmethod + def defaults(cls, config): + cls._auth_defaults(config) + + @classmethod + def _auth_defaults(cls, config): + + # session + check_session = Service(name='check_session', path='/session') + check_session.add_view('GET', 'check_session', klass=cls) + config.add_cornice_service(check_session) + + # login + login = Service(name='login', path='/login') + login.add_view('POST', 'login', klass=cls) + config.add_cornice_service(login) + + # logout + logout = Service(name='logout', path='/logout') + logout.add_view('POST', 'logout', klass=cls) + config.add_cornice_service(logout) + + # become root + become_root = Service(name='become_root', path='/become-root') + become_root.add_view('POST', 'become_root', klass=cls) + config.add_cornice_service(become_root) + + # stop root + stop_root = Service(name='stop_root', path='/stop-root') + stop_root.add_view('POST', 'stop_root', klass=cls) + config.add_cornice_service(stop_root) + + # change password + change_password = Service(name='change_password', path='/change-password') + change_password.add_view('POST', 'change_password', klass=cls) + config.add_cornice_service(change_password) + + +def defaults(config, **kwargs): + base = globals() + + AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView']) + AuthenticationView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/__init__.py b/tailbone/api/batch/__init__.py new file mode 100644 index 00000000..bdf58438 --- /dev/null +++ b/tailbone/api/batch/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2019 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Batches +""" + +from __future__ import unicode_literals, absolute_import + +from .core import APIBatchView, APIBatchRowView, BatchAPIMasterView diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py new file mode 100644 index 00000000..f7bc9333 --- /dev/null +++ b/tailbone/api/batch/core.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Batch Views +""" + +import logging +import warnings + +from cornice import Service + +from tailbone.api import APIMasterView + + +log = logging.getLogger(__name__) + + +class APIBatchMixin(object): + """ + Base class for all API views which are meant to handle "batch" *and/or* + "batch row" data. + """ + + def get_batch_class(self): + model_class = self.get_model_class() + if hasattr(model_class, '__batch_class__'): + return model_class.__batch_class__ + return model_class + + def get_handler(self): + """ + Returns a `BatchHandler` instance for the view. All (?) custom batch + API views should define a default handler class; however this may in all + (?) cases be overridden by config also. The specific setting required + to do so will depend on the 'key' for the type of batch involved, e.g. + assuming the 'vendor_catalog' batch: + + .. code-block:: ini + + [rattail.batch] + vendor_catalog.handler = myapp.batch.vendorcatalog:CustomCatalogHandler + + Note that the 'key' for a batch is generally the same as its primary + table name, although technically it is whatever value returns from the + ``batch_key`` attribute of the main batch model class. + """ + app = self.get_rattail_app() + key = self.get_batch_class().batch_key + return app.get_batch_handler(key, default=self.default_handler_spec) + + +class APIBatchView(APIBatchMixin, APIMasterView): + """ + Base class for all API views which are meant to handle "batch" *and/or* + "batch row" data. + """ + supports_toggle_complete = False + supports_execute = False + + def __init__(self, request, **kwargs): + super(APIBatchView, self).__init__(request, **kwargs) + self.batch_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated; " + "please use `batch_handler` instead", + DeprecationWarning, stacklevel=2) + return self.batch_handler + + def normalize(self, batch): + app = self.get_rattail_app() + created = app.localtime(batch.created, from_utc=True) + + executed = None + if batch.executed: + executed = app.localtime(batch.executed, from_utc=True) + + return { + 'uuid': batch.uuid, + '_str': str(batch), + 'id': batch.id, + 'id_str': batch.id_str, + 'description': batch.description, + 'notes': batch.notes, + 'params': batch.params or {}, + 'rowcount': batch.rowcount, + 'created': str(created), + 'created_display': self.pretty_datetime(created), + 'created_by_uuid': batch.created_by.uuid, + 'created_by_display': str(batch.created_by), + 'complete': batch.complete, + 'status_code': batch.status_code, + 'status_display': batch.STATUS.get(batch.status_code, + str(batch.status_code)), + 'executed': str(executed) if executed else None, + 'executed_display': self.pretty_datetime(executed) if executed else None, + 'executed_by_uuid': batch.executed_by_uuid, + 'executed_by_display': str(batch.executed_by or ''), + 'mutable': self.batch_handler.is_mutable(batch), + } + + def create_object(self, data): + """ + Create a new object instance and populate it with the given data. + + Here we'll invoke the handler for actual batch creation, instead of + typical logic used for simple records. + """ + user = self.request.user + kwargs = dict(data) + kwargs['user'] = user + batch = self.batch_handler.make_batch(self.Session(), **kwargs) + if self.batch_handler.should_populate(batch): + self.batch_handler.do_populate(batch, user) + return batch + + def update_object(self, batch, data): + """ + Logic for updating a main object record. + + Here we want to make sure we set "created by" to the current user, when + creating a new batch. + """ + # we're only concerned with *new* batches here + if not batch.uuid: + + # assign creator; initialize row count + batch.created_by_uuid = self.request.user.uuid + if batch.rowcount is None: + batch.rowcount = 0 + + # then go ahead with usual logic + return super(APIBatchView, self).update_object(batch, data) + + def mark_complete(self): + """ + Mark the given batch as "complete". + """ + batch = self.get_object() + + if batch.executed: + return {'error': "Batch {} has already been executed: {}".format( + batch.id_str, batch.description)} + + if batch.complete: + return {'error': "Batch {} is already marked complete: {}".format( + batch.id_str, batch.description)} + + batch.complete = True + return self._get(obj=batch) + + def mark_incomplete(self): + """ + Mark the given batch as "incomplete". + """ + batch = self.get_object() + + if batch.executed: + return {'error': "Batch {} has already been executed: {}".format( + batch.id_str, batch.description)} + + if not batch.complete: + return {'error': "Batch {} is already marked incomplete: {}".format( + batch.id_str, batch.description)} + + batch.complete = False + return self._get(obj=batch) + + def execute(self): + """ + Execute the given batch. + """ + batch = self.get_object() + + if batch.executed: + return {'error': "Batch {} has already been executed: {}".format( + batch.id_str, batch.description)} + + kwargs = dict(self.request.json_body) + kwargs.pop('user', None) + kwargs.pop('progress', None) + result = self.batch_handler.do_execute(batch, self.request.user, **kwargs) + return {'ok': bool(result), 'batch': self.normalize(batch)} + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_defaults(config) + + @classmethod + def _batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + if cls.supports_toggle_complete: + + # mark complete + mark_complete = Service(name='{}.mark_complete'.format(route_prefix), + path='{}/{{uuid}}/mark-complete'.format(object_url_prefix)) + mark_complete.add_view('POST', 'mark_complete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_complete) + + # mark incomplete + mark_incomplete = Service(name='{}.mark_incomplete'.format(route_prefix), + path='{}/{{uuid}}/mark-incomplete'.format(object_url_prefix)) + mark_incomplete.add_view('POST', 'mark_incomplete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_incomplete) + + if cls.supports_execute: + + # execute batch + execute = Service(name='{}.execute'.format(route_prefix), + path='{}/{{uuid}}/execute'.format(object_url_prefix)) + execute.add_view('POST', 'execute', klass=cls, + permission='{}.execute'.format(permission_prefix)) + config.add_cornice_service(execute) + + +# TODO: deprecate / remove this +BatchAPIMasterView = APIBatchView + + +class APIBatchRowView(APIBatchMixin, APIMasterView): + """ + Base class for all API views which are meant to handle "batch rows" data. + """ + editable = False + supports_quick_entry = False + + def __init__(self, request, **kwargs): + super(APIBatchRowView, self).__init__(request, **kwargs) + self.batch_handler = self.get_handler() + + @property + def handler(self): + warnings.warn("the `handler` property is deprecated; " + "please use `batch_handler` instead", + DeprecationWarning, stacklevel=2) + return self.batch_handler + + def normalize(self, row): + batch = row.batch + return { + 'uuid': row.uuid, + '_str': str(row), + '_parent_str': str(batch), + '_parent_uuid': batch.uuid, + 'batch_uuid': batch.uuid, + 'batch_id': batch.id, + 'batch_id_str': batch.id_str, + 'batch_description': batch.description, + 'batch_complete': batch.complete, + 'batch_executed': bool(batch.executed), + 'batch_mutable': self.batch_handler.is_mutable(batch), + 'sequence': row.sequence, + 'status_code': row.status_code, + 'status_display': row.STATUS.get(row.status_code, str(row.status_code)), + } + + def update_object(self, row, data): + """ + Supplements the default logic as follows: + + Invokes the batch handler's ``refresh_row()`` method after updating the + row's field data per usual. + """ + if not self.batch_handler.is_mutable(row.batch): + return {'error': "Batch is not mutable"} + + # update row per usual + row = super(APIBatchRowView, self).update_object(row, data) + + # okay now we apply handler refresh logic + self.batch_handler.refresh_row(row) + return row + + def delete_object(self, row): + """ + Overrides the default logic as follows: + + Delegates deletion of the row to the batch handler. + """ + self.batch_handler.do_remove_row(row) + + def quick_entry(self): + """ + View for handling "quick entry" user input, for a batch. + """ + data = self.request.json_body + + uuid = data['batch_uuid'] + batch = self.Session.get(self.get_batch_class(), uuid) + if not batch: + raise self.notfound() + + entry = data['quick_entry'] + + try: + row = self.batch_handler.quick_entry(self.Session(), batch, entry) + except Exception as error: + log.warning("quick entry failed for '%s' batch %s: %s", + self.batch_handler.batch_key, batch.id_str, entry, + exc_info=True) + msg = str(error) + if not msg and isinstance(error, NotImplementedError): + msg = "Feature is not implemented" + return {'error': msg} + + if not row: + return {'error': "Could not identify product"} + + self.Session.flush() + result = self._get(obj=row) + result['ok'] = True + return result + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_row_defaults(config) + + @classmethod + def _batch_row_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + + if cls.supports_quick_entry: + + # quick entry + quick_entry = Service(name='{}.quick_entry'.format(route_prefix), + path='{}/quick-entry'.format(collection_url_prefix)) + quick_entry.add_view('POST', 'quick_entry', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(quick_entry) diff --git a/tailbone/api/batch/inventory.py b/tailbone/api/batch/inventory.py new file mode 100644 index 00000000..22b67e54 --- /dev/null +++ b/tailbone/api/batch/inventory.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Inventory Batches +""" + +import decimal + +import sqlalchemy as sa + +from rattail import pod +from rattail.db.model import InventoryBatch, InventoryBatchRow + +from cornice import Service + +from tailbone.api.batch import APIBatchView, APIBatchRowView + + +class InventoryBatchViews(APIBatchView): + + model_class = InventoryBatch + default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' + route_prefix = 'inventory' + permission_prefix = 'batch.inventory' + collection_url_prefix = '/inventory-batches' + object_url_prefix = '/inventory-batch' + supports_toggle_complete = True + + def normalize(self, batch): + data = super().normalize(batch) + + data['mode'] = batch.mode + data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode) + if data['mode_display'] is None and batch.mode is not None: + data['mode_display'] = str(batch.mode) + + data['reason_code'] = batch.reason_code + + return data + + def count_modes(self): + """ + Retrieve info about the available batch count modes. + """ + permission_prefix = self.get_permission_prefix() + if self.request.is_root: + modes = self.batch_handler.get_count_modes() + else: + modes = self.batch_handler.get_allowed_count_modes( + self.Session(), self.request.user, + permission_prefix=permission_prefix) + return modes + + def adjustment_reasons(self): + """ + Retrieve info about the available "reasons" for inventory adjustment + batches. + """ + raw_reasons = self.batch_handler.get_adjustment_reasons(self.Session()) + reasons = [] + for reason in raw_reasons: + reasons.append({ + 'uuid': reason.uuid, + 'code': reason.code, + 'description': reason.description, + 'hidden': reason.hidden, + }) + return reasons + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_defaults(config) + cls._inventory_defaults(config) + + @classmethod + def _inventory_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + + # get count modes + count_modes = Service(name='{}.count_modes'.format(route_prefix), + path='{}/count-modes'.format(collection_url_prefix)) + count_modes.add_view('GET', 'count_modes', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(count_modes) + + # get adjustment reasons + adjustment_reasons = Service(name='{}.adjustment_reasons'.format(route_prefix), + path='{}/adjustment-reasons'.format(collection_url_prefix)) + adjustment_reasons.add_view('GET', 'adjustment_reasons', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(adjustment_reasons) + + +class InventoryBatchRowViews(APIBatchRowView): + + model_class = InventoryBatchRow + default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler' + route_prefix = 'inventory.rows' + permission_prefix = 'batch.inventory' + collection_url_prefix = '/inventory-batch-rows' + object_url_prefix = '/inventory-batch-row' + editable = True + supports_quick_entry = True + + def normalize(self, row): + batch = row.batch + data = super().normalize(row) + app = self.get_rattail_app() + + data['item_id'] = row.item_id + data['upc'] = str(row.upc) + data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name + data['description'] = row.description + data['size'] = row.size + data['full_description'] = row.product.full_description if row.product else row.description + data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None + data['case_quantity'] = app.render_quantity(row.case_quantity or 1) + + data['cases'] = row.cases + data['units'] = row.units + data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + data['quantity_display'] = "{} {}".format( + app.render_quantity(row.cases or row.units), + 'CS' if row.cases else data['unit_uom']) + + data['allow_cases'] = self.batch_handler.allow_cases(batch) + + return data + + def update_object(self, row, data): + """ + Supplements the default logic as follows: + + Converts certain fields within the data, to proper "native" types. + """ + data = dict(data) + + # convert some data types as needed + if 'cases' in data: + if data['cases'] == '': + data['cases'] = None + elif data['cases']: + data['cases'] = decimal.Decimal(data['cases']) + if 'units' in data: + if data['units'] == '': + data['units'] = None + elif data['units']: + data['units'] = decimal.Decimal(data['units']) + + # update row per usual + try: + row = super().update_object(row, data) + except sa.exc.DataError as error: + # detect when user scans barcode for cases/units field + if hasattr(error, 'orig'): + orig = type(error.orig) + if hasattr(orig, '__name__'): + # nb. this particular error is from psycopg2 + if orig.__name__ == 'NumericValueOutOfRange': + return {'error': "Numeric value out of range"} + raise + return row + + +def defaults(config, **kwargs): + base = globals() + + InventoryBatchViews = kwargs.get('InventoryBatchViews', base['InventoryBatchViews']) + InventoryBatchViews.defaults(config) + + InventoryBatchRowViews = kwargs.get('InventoryBatchRowViews', base['InventoryBatchRowViews']) + InventoryBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py new file mode 100644 index 00000000..4f154b21 --- /dev/null +++ b/tailbone/api/batch/labels.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Label Batches +""" + +from rattail.db import model + +from tailbone.api.batch import APIBatchView, APIBatchRowView + + +class LabelBatchViews(APIBatchView): + + model_class = model.LabelBatch + default_handler_spec = 'rattail.batch.labels:LabelBatchHandler' + route_prefix = 'labelbatchviews' + permission_prefix = 'labels.batch' + collection_url_prefix = '/label-batches' + object_url_prefix = '/label-batch' + supports_toggle_complete = True + + +class LabelBatchRowViews(APIBatchRowView): + + model_class = model.LabelBatchRow + default_handler_spec = 'rattail.batch.labels:LabelBatchHandler' + route_prefix = 'api.label_batch_rows' + permission_prefix = 'labels.batch' + collection_url_prefix = '/label-batch-rows' + object_url_prefix = '/label-batch-row' + supports_quick_entry = True + + def normalize(self, row): + batch = row.batch + data = super().normalize(row) + + data['item_id'] = row.item_id + data['upc'] = str(row.upc) + data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name + data['description'] = row.description + data['size'] = row.size + data['full_description'] = row.product.full_description if row.product else row.description + return data + + +def defaults(config, **kwargs): + base = globals() + + LabelBatchViews = kwargs.get('LabelBatchViews', base['LabelBatchViews']) + LabelBatchViews.defaults(config) + + LabelBatchRowViews = kwargs.get('LabelBatchRowViews', base['LabelBatchRowViews']) + LabelBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py new file mode 100644 index 00000000..204be8ad --- /dev/null +++ b/tailbone/api/batch/ordering.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Ordering Batches + +These views expose the basic CRUD interface to "ordering" batches, for the web +API. +""" + +import datetime +import logging + +import sqlalchemy as sa + +from rattail.db.model import PurchaseBatch, PurchaseBatchRow + +from cornice import Service + +from tailbone.api.batch import APIBatchView, APIBatchRowView + + +log = logging.getLogger(__name__) + + +class OrderingBatchViews(APIBatchView): + + model_class = PurchaseBatch + default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + route_prefix = 'orderingbatchviews' + permission_prefix = 'ordering' + collection_url_prefix = '/ordering-batches' + object_url_prefix = '/ordering-batch' + supports_toggle_complete = True + supports_execute = True + + def base_query(self): + """ + Modifies the default logic as follows: + + Adds a condition to the query, to ensure only purchase batches with + "ordering" mode are returned. + """ + model = self.model + query = super().base_query() + query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING) + return query + + def normalize(self, batch): + data = super().normalize(batch) + + data['vendor_uuid'] = batch.vendor.uuid + data['vendor_display'] = str(batch.vendor) + + data['department_uuid'] = batch.department_uuid + data['department_display'] = str(batch.department) if batch.department else None + + data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0) + data['ship_method'] = batch.ship_method + data['notes_to_vendor'] = batch.notes_to_vendor + return data + + def create_object(self, data): + """ + Modifies the default logic as follows: + + Sets the mode to "ordering" for the new batch. + """ + data = dict(data) + if not data.get('vendor_uuid'): + raise ValueError("You must specify the vendor") + data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING + batch = super().create_object(data) + return batch + + def worksheet(self): + """ + Returns primary data for the Ordering Worksheet view. + """ + batch = self.get_object() + if batch.executed: + raise self.forbidden() + + app = self.get_rattail_app() + + # TODO: much of the logic below was copied from the traditional master + # view for ordering batches. should maybe let them share it somehow? + + # organize existing batch rows by product + order_items = {} + for row in batch.active_rows(): + order_items[row.product_uuid] = row + + # organize vendor catalog costs by dept / subdept + departments = {} + costs = self.batch_handler.get_order_form_costs(self.Session(), batch.vendor) + costs = self.batch_handler.sort_order_form_costs(costs) + costs = list(costs) # we must have a stable list for the rest of this + self.batch_handler.decorate_order_form_costs(batch, costs) + for cost in costs: + + department = cost.product.department + if department: + department_dict = departments.setdefault(department.uuid, { + 'uuid': department.uuid, + 'number': department.number, + 'name': department.name, + }) + else: + if None not in departments: + departments[None] = { + 'uuid': None, + 'number': None, + 'name': "", + } + department_dict = departments[None] + + subdepartments = department_dict.setdefault('subdepartments', {}) + + subdepartment = cost.product.subdepartment + if subdepartment: + subdepartment_dict = subdepartments.setdefault(subdepartment.uuid, { + 'uuid': subdepartment.uuid, + 'number': subdepartment.number, + 'name': subdepartment.name, + }) + else: + if None not in subdepartments: + subdepartments[None] = { + 'uuid': None, + 'number': None, + 'name': "", + } + subdepartment_dict = subdepartments[None] + + subdept_costs = subdepartment_dict.setdefault('costs', []) + product = cost.product + subdept_costs.append({ + 'uuid': cost.uuid, + 'upc': str(product.upc), + 'upc_pretty': product.upc.pretty() if product.upc else None, + 'brand_name': product.brand.name if product.brand else None, + 'description': product.description, + 'size': product.size, + 'case_size': cost.case_size, + 'uom_display': "LB" if product.weighed else "EA", + 'vendor_item_code': cost.code, + 'preference': cost.preference, + 'preferred': cost.preference == 1, + 'unit_cost': cost.unit_cost, + 'unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost.unit_cost is not None else "", + # TODO + # 'cases_ordered': None, + # 'units_ordered': None, + # 'po_total': None, + # 'po_total_display': None, + }) + + # sort the (sub)department groupings + sorted_departments = [] + for dept in sorted(departments.values(), key=lambda d: d['name']): + dept['subdepartments'] = sorted(dept['subdepartments'].values(), + key=lambda s: s['name']) + sorted_departments.append(dept) + + # fetch recent purchase history, sort/pad for template convenience + history = self.batch_handler.get_order_form_history(batch, costs, 6) + for i in range(6 - len(history)): + history.append(None) + history = list(reversed(history)) + # must convert some date objects to string, for JSON sake + for h in history: + if not h: + continue + purchase = h.get('purchase') + if purchase: + dt = purchase.get('date_ordered') + if dt and isinstance(dt, datetime.date): + purchase['date_ordered'] = app.render_date(dt) + dt = purchase.get('date_received') + if dt and isinstance(dt, datetime.date): + purchase['date_received'] = app.render_date(dt) + + return { + 'batch': self.normalize(batch), + 'departments': departments, + 'sorted_departments': sorted_departments, + 'history': history, + } + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_defaults(config) + cls._ordering_batch_defaults(config) + + @classmethod + def _ordering_batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # worksheet + worksheet = Service(name='{}.worksheet'.format(route_prefix), + path='{}/{{uuid}}/worksheet'.format(object_url_prefix)) + worksheet.add_view('GET', 'worksheet', klass=cls, + permission='{}.worksheet'.format(permission_prefix)) + config.add_cornice_service(worksheet) + + +class OrderingBatchRowViews(APIBatchRowView): + + model_class = PurchaseBatchRow + default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + route_prefix = 'ordering.rows' + permission_prefix = 'ordering' + collection_url_prefix = '/ordering-batch-rows' + object_url_prefix = '/ordering-batch-row' + supports_quick_entry = True + editable = True + + def normalize(self, row): + data = super().normalize(row) + app = self.get_rattail_app() + batch = row.batch + + data['item_id'] = row.item_id + data['upc'] = str(row.upc) + data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name + data['description'] = row.description + data['size'] = row.size + data['full_description'] = row.product.full_description if row.product else row.description + + # # only provide image url if so configured + # if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): + # data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None + + # unit_uom can vary by product + data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + + data['case_quantity'] = row.case_quantity + data['cases_ordered'] = row.cases_ordered + data['units_ordered'] = row.units_ordered + data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False) + data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False) + + data['po_unit_cost'] = row.po_unit_cost + data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None + data['po_total_calculated'] = row.po_total_calculated + data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None + data['status_code'] = row.status_code + data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code)) + + return data + + def update_object(self, row, data): + """ + Overrides the default logic as follows: + + So far, we only allow updating the ``cases_ordered`` and/or + ``units_ordered`` quantities; therefore ``data`` should have one or + both of those keys. + + This data is then passed to the + :meth:`~rattail:rattail.batch.purchase.PurchaseBatchHandler.update_row_quantity()` + method of the batch handler. + + Note that the "normal" logic for this method is not invoked at all. + """ + if not self.batch_handler.is_mutable(row.batch): + return {'error': "Batch is not mutable"} + + try: + self.batch_handler.update_row_quantity(row, **data) + self.Session.flush() + except Exception as error: + log.warning("update_row_quantity failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} + + return row + + +def defaults(config, **kwargs): + base = globals() + + OrderingBatchViews = kwargs.get('OrderingBatchViews', base['OrderingBatchViews']) + OrderingBatchViews.defaults(config) + + OrderingBatchRowViews = kwargs.get('OrderingBatchRowViews', base['OrderingBatchRowViews']) + OrderingBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py new file mode 100644 index 00000000..b23bff55 --- /dev/null +++ b/tailbone/api/batch/receiving.py @@ -0,0 +1,492 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Receiving Batches +""" + +import logging + +import humanize +import sqlalchemy as sa + +from rattail.db.model import PurchaseBatch, PurchaseBatchRow + +from cornice import Service +from deform import widget as dfwidget + +from tailbone import forms +from tailbone.api.batch import APIBatchView, APIBatchRowView +from tailbone.forms.receiving import ReceiveRow + + +log = logging.getLogger(__name__) + + +class ReceivingBatchViews(APIBatchView): + + model_class = PurchaseBatch + default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + route_prefix = 'receivingbatchviews' + permission_prefix = 'receiving' + collection_url_prefix = '/receiving-batches' + object_url_prefix = '/receiving-batch' + supports_toggle_complete = True + supports_execute = True + + def base_query(self): + model = self.app.model + query = super().base_query() + query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) + return query + + def normalize(self, batch): + data = super().normalize(batch) + + data['vendor_uuid'] = batch.vendor.uuid + data['vendor_display'] = str(batch.vendor) + + data['department_uuid'] = batch.department_uuid + data['department_display'] = str(batch.department) if batch.department else None + + data['po_number'] = batch.po_number + data['po_total'] = batch.po_total + data['invoice_total'] = batch.invoice_total + data['invoice_total_calculated'] = batch.invoice_total_calculated + + data['can_auto_receive'] = self.batch_handler.can_auto_receive(batch) + + return data + + def create_object(self, data): + data = dict(data) + + # all about receiving mode here + data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING + + # assume "receive from PO" if given a PO key + if data.get('purchase_key'): + data['workflow'] = 'from_po' + + return super().create_object(data) + + def auto_receive(self): + """ + View which handles auto-marking as received, all items within + a pending batch. + """ + batch = self.get_object() + self.batch_handler.auto_receive_all_items(batch) + return self._get(obj=batch) + + def mark_receiving_complete(self): + """ + Mark the given batch as "receiving complete". + """ + batch = self.get_object() + + if batch.executed: + return {'error': "Batch {} has already been executed: {}".format( + batch.id_str, batch.description)} + + if batch.complete: + return {'error': "Batch {} is already marked complete: {}".format( + batch.id_str, batch.description)} + + if batch.receiving_complete: + return {'error': "Receiving is already complete for batch {}: {}".format( + batch.id_str, batch.description)} + + batch.receiving_complete = True + return self._get(obj=batch) + + def eligible_purchases(self): + model = self.app.model + uuid = self.request.params.get('vendor_uuid') + vendor = self.Session.get(model.Vendor, uuid) if uuid else None + if not vendor: + return {'error': "Vendor not found"} + + purchases = self.batch_handler.get_eligible_purchases( + vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING) + + purchases = [self.normalize_eligible_purchase(p) + for p in purchases] + + return {'purchases': purchases} + + def normalize_eligible_purchase(self, purchase): + return self.batch_handler.normalize_eligible_purchase(purchase) + + def render_eligible_purchase(self, purchase): + return self.batch_handler.render_eligible_purchase(purchase) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_defaults(config) + cls._receiving_batch_defaults(config) + + @classmethod + def _receiving_batch_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # auto_receive + auto_receive = Service(name='{}.auto_receive'.format(route_prefix), + path='{}/{{uuid}}/auto-receive'.format(object_url_prefix)) + auto_receive.add_view('GET', 'auto_receive', klass=cls, + permission='{}.auto_receive'.format(permission_prefix)) + config.add_cornice_service(auto_receive) + + # mark_receiving_complete + mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix), + path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix)) + mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(mark_receiving_complete) + + # eligible purchases + eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix), + path='{}/eligible-purchases'.format(collection_url_prefix)) + eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls, + permission='{}.create'.format(permission_prefix)) + config.add_cornice_service(eligible_purchases) + + +class ReceivingBatchRowViews(APIBatchRowView): + + model_class = PurchaseBatchRow + default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' + route_prefix = 'receiving.rows' + permission_prefix = 'receiving' + collection_url_prefix = '/receiving-batch-rows' + object_url_prefix = '/receiving-batch-row' + supports_quick_entry = True + + def make_filter_spec(self): + model = self.app.model + filters = super().make_filter_spec() + if filters: + + # must translate certain convenience filters + orig_filters, filters = filters, [] + for filtr in orig_filters: + + # # is_received + # # NOTE: this is only relevant for truck dump or "from scratch" + # if filtr['field'] == 'is_received' and filtr['op'] == 'eq' and filtr['value'] is True: + # filters.extend([ + # {'or': [ + # {'field': 'cases_received', 'op': '!=', 'value': 0}, + # {'field': 'units_received', 'op': '!=', 'value': 0}, + # ]}, + # ]) + + # is_incomplete + if filtr['field'] == 'is_incomplete' and filtr['op'] == 'eq' and filtr['value'] is True: + # looking for any rows with "ordered" quantity, but where the + # status does *not* signify a "settled" row so to speak + # TODO: would be nice if we had a simple flag to leverage? + filters.extend([ + {'or': [ + {'field': 'cases_ordered', 'op': '!=', 'value': 0}, + {'field': 'units_ordered', 'op': '!=', 'value': 0}, + ]}, + {'field': 'status_code', 'op': 'not_in', 'value': [ + model.PurchaseBatchRow.STATUS_OK, + model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND, + model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS, + ]}, + ]) + + # is_invalid + elif filtr['field'] == 'is_invalid' and filtr['op'] == 'eq' and filtr['value'] is True: + filters.extend([ + {'field': 'status_code', 'op': 'in', 'value': [ + model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND, + model.PurchaseBatchRow.STATUS_COST_NOT_FOUND, + model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN, + model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS, + ]}, + ]) + + # is_unexpected + elif filtr['field'] == 'is_unexpected' and filtr['op'] == 'eq' and filtr['value'] is True: + # looking for any rows which do *not* have "ordered/shipped" quantity + filters.extend([ + {'and': [ + {'or': [ + {'field': 'cases_ordered', 'op': 'is_null'}, + {'field': 'cases_ordered', 'op': '==', 'value': 0}, + ]}, + {'or': [ + {'field': 'units_ordered', 'op': 'is_null'}, + {'field': 'units_ordered', 'op': '==', 'value': 0}, + ]}, + {'or': [ + {'field': 'cases_shipped', 'op': 'is_null'}, + {'field': 'cases_shipped', 'op': '==', 'value': 0}, + ]}, + {'or': [ + {'field': 'units_shipped', 'op': 'is_null'}, + {'field': 'units_shipped', 'op': '==', 'value': 0}, + ]}, + {'or': [ + # but "unexpected" also implies we have some confirmed amount(s) + {'field': 'cases_received', 'op': '!=', 'value': 0}, + {'field': 'units_received', 'op': '!=', 'value': 0}, + {'field': 'cases_damaged', 'op': '!=', 'value': 0}, + {'field': 'units_damaged', 'op': '!=', 'value': 0}, + {'field': 'cases_expired', 'op': '!=', 'value': 0}, + {'field': 'units_expired', 'op': '!=', 'value': 0}, + ]}, + ]}, + ]) + + # is_damaged + elif filtr['field'] == 'is_damaged' and filtr['op'] == 'eq' and filtr['value'] is True: + filters.extend([ + {'or': [ + {'field': 'cases_damaged', 'op': '!=', 'value': 0}, + {'field': 'units_damaged', 'op': '!=', 'value': 0}, + ]}, + ]) + + # is_expired + elif filtr['field'] == 'is_expired' and filtr['op'] == 'eq' and filtr['value'] is True: + filters.extend([ + {'or': [ + {'field': 'cases_expired', 'op': '!=', 'value': 0}, + {'field': 'units_expired', 'op': '!=', 'value': 0}, + ]}, + ]) + + # is_missing + elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True: + filters.extend([ + {'or': [ + {'field': 'cases_missing', 'op': '!=', 'value': 0}, + {'field': 'units_missing', 'op': '!=', 'value': 0}, + ]}, + ]) + + else: # just some filter, use as-is + filters.append(filtr) + + return filters + + def normalize(self, row): + data = super().normalize(row) + model = self.app.model + + batch = row.batch + prodder = self.app.get_products_handler() + + data['product_uuid'] = row.product_uuid + data['item_id'] = row.item_id + data['upc'] = str(row.upc) + data['upc_pretty'] = row.upc.pretty() if row.upc else None + data['brand_name'] = row.brand_name + data['description'] = row.description + data['size'] = row.size + data['full_description'] = row.product.full_description if row.product else row.description + + # only provide image url if so configured + if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): + data['image_url'] = prodder.get_image_url(product=row.product, upc=row.upc) + + # unit_uom can vary by product + data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' + + data['case_quantity'] = row.case_quantity + data['order_quantities_known'] = batch.order_quantities_known + + data['cases_ordered'] = row.cases_ordered + data['units_ordered'] = row.units_ordered + + data['cases_shipped'] = row.cases_shipped + data['units_shipped'] = row.units_shipped + + data['cases_received'] = row.cases_received + data['units_received'] = row.units_received + + data['cases_damaged'] = row.cases_damaged + data['units_damaged'] = row.units_damaged + + data['cases_expired'] = row.cases_expired + data['units_expired'] = row.units_expired + + data['cases_missing'] = row.cases_missing + data['units_missing'] = row.units_missing + + cases, units = self.batch_handler.get_unconfirmed_counts(row) + data['cases_unconfirmed'] = cases + data['units_unconfirmed'] = units + + data['po_unit_cost'] = row.po_unit_cost + data['po_total'] = row.po_total + + data['invoice_number'] = row.invoice_number + data['invoice_unit_cost'] = row.invoice_unit_cost + data['invoice_total'] = row.invoice_total + data['invoice_total_calculated'] = row.invoice_total_calculated + + data['allow_cases'] = self.batch_handler.allow_cases() + + data['quick_receive'] = self.rattail_config.getbool( + 'rattail.batch', 'purchase.mobile_quick_receive', + default=True) + + if batch.order_quantities_known: + data['quick_receive_all'] = self.rattail_config.getbool( + 'rattail.batch', 'purchase.mobile_quick_receive_all', + default=False) + + # TODO: this was copied from regular view receive_row() method; should merge + if data['quick_receive'] and data.get('quick_receive_all'): + if data['allow_cases']: + data['quick_receive_uom'] = 'CS' + raise NotImplementedError("TODO: add CS support for quick_receive_all") + else: + data['quick_receive_uom'] = data['unit_uom'] + accounted_for = self.batch_handler.get_units_accounted_for(row) + remainder = self.batch_handler.get_units_ordered(row) - accounted_for + + if accounted_for: + # some product accounted for; button should receive "remainder" only + if remainder: + remainder = self.app.render_quantity(remainder) + data['quick_receive_quantity'] = remainder + data['quick_receive_text'] = "Receive Remainder ({} {})".format( + remainder, data['unit_uom']) + else: + # unless there is no remainder, in which case disable it + data['quick_receive'] = False + + else: # nothing yet accounted for, button should receive "all" + if not remainder: + log.warning("quick receive remainder is empty for row %s", row.uuid) + remainder = self.app.render_quantity(remainder) + data['quick_receive_quantity'] = remainder + data['quick_receive_text'] = "Receive ALL ({} {})".format( + remainder, data['unit_uom']) + + data['unexpected_alert'] = None + if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered: + warn = True + if batch.is_truck_dump_parent() and row.product: + uuids = [child.uuid for child in batch.truck_dump_children] + if uuids: + count = self.Session.query(model.PurchaseBatchRow)\ + .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\ + .filter(model.PurchaseBatchRow.product == row.product)\ + .count() + if count: + warn = False + if warn: + data['unexpected_alert'] = "This item was NOT on the original purchase order." + + # TODO: surely the caller of API should determine this flag? + # maybe alert user if they've already received some of this product + alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received', + default=False) + if alert_received: + data['received_alert'] = None + if self.batch_handler.get_units_confirmed(row): + msg = "You have already received some of this product; last update was {}.".format( + humanize.naturaltime(self.app.make_utc() - row.modified)) + data['received_alert'] = msg + + return data + + def receive(self): + """ + View which handles "receiving" against a particular batch row. + """ + model = self.app.model + + # first do basic input validation + schema = ReceiveRow().bind(session=self.Session()) + form = forms.Form(schema=schema, request=self.request) + # TODO: this seems hacky, but avoids "complex" date value parsing + form.set_widget('expiration_date', dfwidget.TextInputWidget()) + if not form.validate(): + log.warning("form did not validate: %s", + form.make_deform_form().error) + return {'error': "Form did not validate"} + + # fetch / validate row object + row = self.Session.get(model.PurchaseBatchRow, form.validated['row']) + if row is not self.get_object(): + return {'error': "Specified row does not match the route!"} + + # handler takes care of the row receiving logic for us + kwargs = dict(form.validated) + del kwargs['row'] + try: + self.batch_handler.receive_row(row, **kwargs) + self.Session.flush() + except Exception as error: + log.warning("receive() failed", exc_info=True) + if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'): + error = str(error.orig) + else: + error = str(error) + return {'error': error} + + return self._get(obj=row) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._batch_row_defaults(config) + cls._receiving_batch_row_defaults(config) + + @classmethod + def _receiving_batch_row_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # receive (row) + receive = Service(name='{}.receive'.format(route_prefix), + path='{}/{{uuid}}/receive'.format(object_url_prefix)) + receive.add_view('POST', 'receive', klass=cls, + permission='{}.edit_row'.format(permission_prefix)) + config.add_cornice_service(receive) + + +def defaults(config, **kwargs): + base = globals() + + ReceivingBatchViews = kwargs.get('ReceivingBatchViews', base['ReceivingBatchViews']) + ReceivingBatchViews.defaults(config) + + ReceivingBatchRowViews = kwargs.get('ReceivingBatchRowViews', base['ReceivingBatchRowViews']) + ReceivingBatchRowViews.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/common.py b/tailbone/api/common.py new file mode 100644 index 00000000..6cacfb06 --- /dev/null +++ b/tailbone/api/common.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - "Common" Views +""" + +from collections import OrderedDict + +from rattail.util import get_pkg_version + +from cornice import Service +from cornice.service import get_services +from cornice_swagger import CorniceSwagger + +from tailbone import forms +from tailbone.forms.common import Feedback +from tailbone.api import APIView, api +from tailbone.db import Session + + +class CommonView(APIView): + """ + Misc. "common" views for the API. + + .. attribute:: feedback_email_key + + This is the email key which will be used when sending "user feedback" + email. Default value is ``'user_feedback'``. + """ + feedback_email_key = 'user_feedback' + + @api + def about(self): + """ + Generic view to show "about project" info page. + """ + packages = self.get_packages() + return { + 'project_title': self.get_project_title(), + 'project_version': self.get_project_version(), + 'packages': packages, + 'package_names': list(packages), + } + + def get_project_title(self): + app = self.get_rattail_app() + return app.get_title() + + def get_project_version(self): + app = self.get_rattail_app() + return app.get_version() + + def get_packages(self): + """ + Should return the full set of packages which should be displayed on the + 'about' page. + """ + return OrderedDict([ + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), + ]) + + @api + def feedback(self): + """ + View to handle user feedback form submits. + """ + app = self.get_rattail_app() + model = self.model + # TODO: this logic was copied from tailbone.views.common and is largely + # identical; perhaps should merge somehow? + schema = Feedback().bind(session=Session()) + form = forms.Form(schema=schema, request=self.request) + if form.validate(): + data = dict(form.validated) + + # figure out who the sending user is, if any + if self.request.user: + data['user'] = self.request.user + elif data['user']: + data['user'] = Session.get(model.User, data['user']) + + # TODO: should provide URL to view user + if data['user']: + data['user_url'] = '#' # TODO: could get from config? + + data['client_ip'] = self.request.client_addr + email_key = data['email_key'] or self.feedback_email_key + app.send_email(email_key, data=data) + return {'ok': True} + + return {'error': "Form did not validate!"} + + def swagger(self): + doc = CorniceSwagger(get_services()) + app = self.get_rattail_app() + spec = doc.generate(f"{app.get_node_title()} API docs", + app.get_version(), + base_path='/api') # TODO + return spec + + @classmethod + def defaults(cls, config): + cls._common_defaults(config) + + @classmethod + def _common_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') + app = rattail_config.get_app() + + # about + about = Service(name='about', path='/about') + about.add_view('GET', 'about', klass=cls) + config.add_cornice_service(about) + + # feedback + feedback = Service(name='feedback', path='/feedback') + feedback.add_view('POST', 'feedback', klass=cls, + permission='common.feedback') + config.add_cornice_service(feedback) + + # swagger + swagger = Service(name='swagger', + path='/swagger.json', + description=f"OpenAPI documentation for {app.get_title()}") + swagger.add_view('GET', 'swagger', klass=cls, + permission='common.api_swagger') + config.add_cornice_service(swagger) + + +def defaults(config, **kwargs): + base = globals() + + CommonView = kwargs.get('CommonView', base['CommonView']) + CommonView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/core.py b/tailbone/api/core.py new file mode 100644 index 00000000..0d8eec32 --- /dev/null +++ b/tailbone/api/core.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Core Views +""" + +from tailbone.views import View + + +def api(view_meth): + """ + Common decorator for all API views. Ideally this would not be needed..but + for now, alas, it is. + """ + def wrapped(view, *args, **kwargs): + + # TODO: why doesn't this work here...? (instead we have to repeat this + # code in lots of other places) + # if view.request.method == 'OPTIONS': + # return view.request.response + + # invoke the view logic first, since presumably it may involve a + # redirect in which case we don't really need to add the CSRF token. + # main known use case for this is the /logout endpoint - if that gets + # hit then the "current" (old) session will be destroyed, in which case + # we can't use the token from that, but instead must generate a new one. + result = view_meth(view, *args, **kwargs) + + # explicitly set CSRF token cookie, unless OPTIONS request + # TODO: why doesn't pyramid do this for us again? + if view.request.method != 'OPTIONS': + view.request.response.set_cookie(name='XSRF-TOKEN', + value=view.request.session.get_csrf_token()) + + return result + + return wrapped + + +class APIView(View): + """ + Base class for all API views. + """ + + def pretty_datetime(self, dt): + if not dt: + return "" + return dt.strftime('%Y-%m-%d @ %I:%M %p') + + def get_user_info(self, user): + """ + This method is present on *all* API views, and is meant to provide a + single means of obtaining "common" user info, for return to the caller. + Such info may be returned in several places, e.g. upon login but also + in the "check session" call, or e.g. as part of a broader return value + from any other call. + + :returns: Dictionary of user info data, ready for JSON serialization. + + Note that you should *not* (usually) override this method in any view, + but instead configure a "supplemental" function which can then add or + replace info entries. Config for that looks like e.g.: + + .. code-block:: ini + + [tailbone.api] + extra_user_info = poser.web.api.util:extra_user_info + + Note that the above config assumes a simple *function* defined in your + ``util`` module; such a function would look like e.g.:: + + def extra_user_info(request, user, **info): + # add favorite color + info['favorite_color'] = 'green' + # override display name + info['display_name'] = "TODO" + # remove short_name + info.pop('short_name', None) + return info + """ + app = self.get_rattail_app() + auth = app.get_auth_handler() + + # basic / default info + is_admin = auth.user_is_admin(user) + employee = app.get_employee(user) + info = { + 'uuid': user.uuid, + 'username': user.username, + 'display_name': user.display_name, + 'short_name': auth.get_short_display_name(user), + 'is_admin': is_admin, + 'is_root': is_admin and self.request.session.get('is_root', False), + 'employee_uuid': employee.uuid if employee else None, + 'email_address': app.get_contact_email_address(user), + } + + # maybe get/use "extra" info + extra = self.rattail_config.get('tailbone.api', 'extra_user_info', + usedb=False) + if extra: + extra = app.load_object(extra) + info = extra(self.request, user, **info) + + return info diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py new file mode 100644 index 00000000..85d28c24 --- /dev/null +++ b/tailbone/api/customers.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Customer Views +""" + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class CustomerView(APIMasterView): + """ + API views for Customer data + """ + model_class = model.Customer + collection_url_prefix = '/customers' + object_url_prefix = '/customer' + supports_autocomplete = True + autocomplete_fieldname = 'name' + + def normalize(self, customer): + return { + 'uuid': customer.uuid, + '_str': str(customer), + 'id': customer.id, + 'number': customer.number, + 'name': customer.name, + } + + +def defaults(config, **kwargs): + base = globals() + + CustomerView = kwargs.get('CustomerView', base['CustomerView']) + CustomerView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/essentials.py b/tailbone/api/essentials.py new file mode 100644 index 00000000..7b151578 --- /dev/null +++ b/tailbone/api/essentials.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Essential views for convenient includes +""" + + +def defaults(config, **kwargs): + mod = lambda spec: kwargs.get(spec, spec) + + config.include(mod('tailbone.api.auth')) + config.include(mod('tailbone.api.common')) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/labels.py b/tailbone/api/labels.py new file mode 100644 index 00000000..8bc11f8f --- /dev/null +++ b/tailbone/api/labels.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Label Views +""" + +from __future__ import unicode_literals, absolute_import + +from rattail.db.model import LabelProfile + +from tailbone.api import APIMasterView + + +class LabelProfileView(APIMasterView): + """ + API views for Label Profile data + """ + model_class = LabelProfile + collection_url_prefix = '/label-profiles' + object_url_prefix = '/label-profile' + + +def defaults(config, **kwargs): + base = globals() + + LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView']) + LabelProfileView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/master.py b/tailbone/api/master.py new file mode 100644 index 00000000..551d6428 --- /dev/null +++ b/tailbone/api/master.py @@ -0,0 +1,618 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Master View +""" + +import json + +from rattail.db.util import get_fieldnames + +from cornice import resource, Service + +from tailbone.api import APIView +from tailbone.db import Session +from tailbone.util import SortColumn + + +class APIMasterView(APIView): + """ + Base class for data model REST API views. + """ + listable = True + creatable = True + viewable = True + editable = True + deletable = True + supports_autocomplete = False + supports_download = False + supports_rawbytes = False + + @property + def Session(self): + return Session + + @classmethod + def get_model_class(cls): + if hasattr(cls, 'model_class'): + return cls.model_class + raise NotImplementedError("must set `model_class` for {}".format(cls.__name__)) + + @classmethod + def get_normalized_model_name(cls): + if hasattr(cls, 'normalized_model_name'): + return cls.normalized_model_name + return cls.get_model_class().__name__.lower() + + @classmethod + def get_route_prefix(cls): + """ + Returns a prefix which (by default) applies to all routes provided by + this view class. + """ + prefix = getattr(cls, 'route_prefix', None) + if prefix: + return prefix + model_name = cls.get_normalized_model_name() + return '{}s'.format(model_name) + + @classmethod + def get_permission_prefix(cls): + """ + Returns a prefix which (by default) applies to all permissions + leveraged by this view class. + """ + prefix = getattr(cls, 'permission_prefix', None) + if prefix: + return prefix + return cls.get_route_prefix() + + @classmethod + def get_collection_url_prefix(cls): + """ + Returns a prefix which (by default) applies to all "collection" URLs + provided by this view class. + """ + prefix = getattr(cls, 'collection_url_prefix', None) + if prefix: + return prefix + return '/{}'.format(cls.get_route_prefix()) + + @classmethod + def get_object_url_prefix(cls): + """ + Returns a prefix which (by default) applies to all "object" URLs + provided by this view class. + """ + prefix = getattr(cls, 'object_url_prefix', None) + if prefix: + return prefix + return '/{}'.format(cls.get_route_prefix()) + + @classmethod + def get_object_key(cls): + if hasattr(cls, 'object_key'): + return cls.object_key + return cls.get_normalized_model_name() + + @classmethod + def get_collection_key(cls): + if hasattr(cls, 'collection_key'): + return cls.collection_key + return '{}s'.format(cls.get_object_key()) + + @classmethod + def establish_method(cls, method_name): + """ + Establish the given HTTP method for this Cornice Resource. + + Cornice will auto-register any class methods for a resource, if they + are named according to what it expects (i.e. 'get', 'collection_get' + etc.). Tailbone API tries to make things automagical for the sake of + e.g. Poser logic, but in this case if we predefine all of these methods + and then some subclass view wants to *not* allow one, it's not clear + how to "undefine" it per se. Or at least, the more straightforward + thing (I think) is to not define such a method in the first place, if + it was not wanted. + + Enter ``establish_method()``, which is what finally "defines" each + resource method according to what the subclass has declared via its + various attributes (:attr:`creatable`, :attr:`deletable` etc.). + + Note that you will not likely have any need to use this + ``establish_method()`` yourself! But we describe its purpose here, for + clarity. + """ + def method(self): + internal_method = getattr(self, '_{}'.format(method_name)) + return internal_method() + + setattr(cls, method_name, method) + + def make_filter_spec(self): + if not self.request.GET.has_key('filters'): + return [] + + filters = json.loads(self.request.GET.getone('filters')) + return filters + + def make_sort_spec(self): + + # we prefer a "native sort" + if self.request.GET.has_key('nativeSort'): + return json.loads(self.request.GET.getone('nativeSort')) + + # these params are based on 'vuetable-2' + # https://www.vuetable.com/guide/sorting.html#initial-sorting-order + if 'sort' in self.request.params: + sort = self.request.params['sort'] + sortkey, sortdir = sort.split('|') + if sortdir != 'desc': + sortdir = 'asc' + return [ + { + # 'model': self.model_class.__name__, + 'field': sortkey, + 'direction': sortdir, + }, + ] + + # these params are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + if 'orderBy' in self.request.params and 'ascending' in self.request.params: + sortcol = self.interpret_sortcol(self.request.params['orderBy']) + if sortcol: + spec = { + 'field': sortcol.field_name, + 'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc', + } + if sortcol.model_name: + spec['model'] = sortcol.model_name + return [spec] + + def interpret_sortcol(self, order_by): + """ + This must return a ``SortColumn`` object based on parsing of the given + ``order_by`` string, which is "raw" as received from the client. + + Please override as necessary, but in all cases you should invoke + :meth:`sortcol()` to obtain your return value. Default behavior + for this method is to simply do (only) that:: + + return self.sortcol(order_by) + + Note that you can also return ``None`` here, if the given ``order_by`` + string does not represent a valid sort. + """ + return self.sortcol(order_by) + + def sortcol(self, field_name, model_name=None): + """ + Return a simple ``SortColumn`` object which denotes the field and + optionally, the model, to be used when sorting. + """ + if not model_name: + model_name = self.model_class.__name__ + return SortColumn(field_name, model_name) + + def join_for_sort_spec(self, query, sort_spec): + """ + This should apply any joins needed on the given query, to accommodate + requested sorting as per ``sort_spec`` - which will be non-empty but + otherwise no claims are made regarding its contents. + + Please override as necessary, but in all cases you should return a + query, either untouched or else with join(s) applied. + """ + model_name = sort_spec[0].get('model') + return self.join_for_sort_model(query, model_name) + + def join_for_sort_model(self, query, model_name): + """ + This should apply any joins needed on the given query, to accommodate + requested sorting on a field associated with the given model. + + Please override as necessary, but in all cases you should return a + query, either untouched or else with join(s) applied. + """ + return query + + def make_pagination_spec(self): + + # these params are based on 'vuetable-2' + # https://github.com/ratiw/vuetable-2-tutorial/wiki/prerequisite#sample-api-endpoint + if 'page' in self.request.params and 'per_page' in self.request.params: + page = self.request.params['page'] + per_page = self.request.params['per_page'] + if page.isdigit() and per_page.isdigit(): + return int(page), int(per_page) + + # these params are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + if 'page' in self.request.params and 'limit' in self.request.params: + page = self.request.params['page'] + limit = self.request.params['limit'] + if page.isdigit() and limit.isdigit(): + return int(page), int(limit) + + def base_query(self): + cls = self.get_model_class() + query = self.Session.query(cls) + return query + + def get_fieldnames(self): + if not hasattr(self, '_fieldnames'): + self._fieldnames = get_fieldnames( + self.rattail_config, self.model_class, + columns=True, proxies=True, relations=False) + return self._fieldnames + + def normalize(self, obj): + data = {'_str': str(obj)} + + for field in self.get_fieldnames(): + data[field] = getattr(obj, field) + + return data + + def _collection_get(self): + from sa_filters import apply_filters, apply_sort, apply_pagination + + query = self.base_query() + context = {} + + # maybe filter query + filter_spec = self.make_filter_spec() + if filter_spec: + query = apply_filters(query, filter_spec) + + # maybe sort query + sort_spec = self.make_sort_spec() + if sort_spec: + query = self.join_for_sort_spec(query, sort_spec) + query = apply_sort(query, sort_spec) + + # maybe paginate query + pagination_spec = self.make_pagination_spec() + if pagination_spec: + number, size = pagination_spec + query, pagination = apply_pagination(query, page_number=number, page_size=size) + + # these properties are based on 'vuetable-2' + # https://www.vuetable.com/guide/pagination.html#how-the-pagination-component-works + context['total'] = pagination.total_results + context['per_page'] = pagination.page_size + context['current_page'] = pagination.page_number + context['last_page'] = pagination.num_pages + context['from'] = pagination.page_size * (pagination.page_number - 1) + 1 + to = pagination.page_size * (pagination.page_number - 1) + pagination.page_size + if to > pagination.total_results: + context['to'] = pagination.total_results + else: + context['to'] = to + + # these properties are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + context['count'] = pagination.total_results + + objects = [self.normalize(obj) for obj in query] + + # TODO: test this for ratbob! + context[self.get_collection_key()] = objects + + # these properties are based on 'vue-tables-2' + # https://github.com/matfish2/vue-tables-2#server-side + context['data'] = objects + if 'count' not in context: + context['count'] = len(objects) + + return context + + def get_object(self, uuid=None): + if not uuid: + uuid = self.request.matchdict['uuid'] + + obj = self.Session.get(self.get_model_class(), uuid) + if obj: + return obj + + raise self.notfound() + + def _get(self, obj=None, uuid=None): + if not obj: + obj = self.get_object(uuid=uuid) + key = self.get_object_key() + normal = self.normalize(obj) + return {key: normal, 'data': normal} + + def _collection_post(self): + """ + Default method for actually processing a POST request for the + collection, aka. "create new object". + """ + # assume our data comes only from request JSON body + data = self.request.json_body + + # add instance to session, and return data for it + try: + obj = self.create_object(data) + except Exception as error: + return self.json_response({'error': str(error)}) + else: + self.Session.flush() + return self._get(obj) + + def create_object(self, data): + """ + Create a new object instance and populate it with the given data. + + Note that this method by default will only populate *simple* fields, so + you may need to subclass and override to add more complex field logic. + """ + # create new instance of model class + cls = self.get_model_class() + obj = cls() + + # "update" new object with given data + obj = self.update_object(obj, data) + + # that's all we can do here, subclass must override if more needed + self.Session.add(obj) + return obj + + def _post(self, uuid=None): + """ + Default method for actually processing a POST request for an object, + aka. "update existing object". + """ + if not uuid: + uuid = self.request.matchdict['uuid'] + obj = self.Session.get(self.get_model_class(), uuid) + if not obj: + raise self.notfound() + + # assume our data comes only from request JSON body + data = self.request.json_body + + # try to update data for object, returning error as necessary + obj = self.update_object(obj, data) + if isinstance(obj, dict) and 'error' in obj: + return {'error': obj['error']} + + # return data for object + self.Session.flush() + return self._get(obj) + + def update_object(self, obj, data): + """ + Update the given object instance with the given data. + + Note that this method by default will only update *simple* fields, so + you may need to subclass and override to add more complex field logic. + """ + # set values for simple fields only + for key, value in data.items(): + if hasattr(obj, key): + # TODO: what about datetime, decimal etc.? + setattr(obj, key, value) + + # that's all we can do here, subclass must override if more needed + return obj + + ############################## + # delete + ############################## + + def _delete(self): + """ + View to handle DELETE action for an existing record/object. + """ + obj = self.get_object() + self.delete_object(obj) + + def delete_object(self, obj): + """ + Delete the object, or mark it as deleted, or whatever you need to do. + """ + # flush immediately to force any pending integrity errors etc. + self.Session.delete(obj) + self.Session.flush() + + ############################## + # download + ############################## + + def download(self): + """ + GET view allowing for download of a single file, which is attached to a + given record. + """ + obj = self.get_object() + + filename = self.request.GET.get('filename', None) + if not filename: + raise self.notfound() + path = self.download_path(obj, filename) + + response = self.file_response(path) + return response + + def download_path(self, obj, filename): + """ + Should return absolute path on disk, for the given object and filename. + Result will be used to return a file response to client. + """ + raise NotImplementedError + + def rawbytes(self): + """ + GET view allowing for direct access to the raw bytes of a file, which + is attached to a given record. Basically the same as 'download' except + this does not come as an attachment. + """ + obj = self.get_object() + + # TODO: is this really needed? + # filename = self.request.GET.get('filename', None) + # if filename: + # path = self.download_path(obj, filename) + # return self.file_response(path, attachment=False) + + return self.rawbytes_response(obj) + + def rawbytes_response(self, obj): + raise NotImplementedError + + ############################## + # autocomplete + ############################## + + def autocomplete(self): + """ + View which accepts a single ``term`` param, and returns a list of + autocomplete results to match. + """ + term = self.request.params.get('term', '').strip() + term = self.prepare_autocomplete_term(term) + if not term: + return [] + + results = self.get_autocomplete_data(term) + return [{'label': self.autocomplete_display(x), + 'value': self.autocomplete_value(x)} + for x in results] + + @property + def autocomplete_fieldname(self): + raise NotImplementedError("You must define `autocomplete_fieldname` " + "attribute for API view class: {}".format( + self.__class__)) + + def autocomplete_display(self, obj): + return getattr(obj, self.autocomplete_fieldname) + + def autocomplete_value(self, obj): + return obj.uuid + + def get_autocomplete_data(self, term): + query = self.make_autocomplete_query(term) + return query.all() + + def make_autocomplete_query(self, term): + model_class = self.get_model_class() + query = self.Session.query(model_class) + query = self.filter_autocomplete_query(query) + + field = getattr(model_class, self.autocomplete_fieldname) + query = query.filter(field.ilike('%%%s%%' % term))\ + .order_by(field) + + return query + + def filter_autocomplete_query(self, query): + return query + + def prepare_autocomplete_term(self, term): + """ + If necessary, massage the incoming search term for use with the + autocomplete query. + """ + return term + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # first, the primary resource API + + # list/search + if cls.listable: + cls.establish_method('collection_get') + resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix)) + + # create + if cls.creatable: + cls.establish_method('collection_post') + if hasattr(cls, 'permission_to_create'): + permission = cls.permission_to_create + else: + permission = '{}.create'.format(permission_prefix) + resource.add_view(cls.collection_post, permission=permission) + + # view + if cls.viewable: + cls.establish_method('get') + resource.add_view(cls.get, permission='{}.view'.format(permission_prefix)) + + # edit + if cls.editable: + cls.establish_method('post') + resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix)) + + # delete + if cls.deletable: + cls.establish_method('delete') + resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix)) + + # register primary resource API via cornice + object_resource = resource.add_resource( + cls, + collection_path=collection_url_prefix, + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}'.format(object_url_prefix)) + config.add_cornice_resource(object_resource) + + # now for some more "custom" things, which are still somewhat generic + + # autocomplete + if cls.supports_autocomplete: + autocomplete = Service(name='{}.autocomplete'.format(route_prefix), + path='{}/autocomplete'.format(collection_url_prefix)) + autocomplete.add_view('GET', 'autocomplete', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(autocomplete) + + # download + if cls.supports_download: + download = Service(name='{}.download'.format(route_prefix), + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}/download'.format(object_url_prefix)) + download.add_view('GET', 'download', klass=cls, + permission='{}.download'.format(permission_prefix)) + config.add_cornice_service(download) + + # rawbytes + if cls.supports_rawbytes: + rawbytes = Service(name='{}.rawbytes'.format(route_prefix), + # TODO: probably should allow for other (composite?) key fields + path='{}/{{uuid}}/rawbytes'.format(object_url_prefix)) + rawbytes.add_view('GET', 'rawbytes', klass=cls, + permission='{}.download'.format(permission_prefix)) + config.add_cornice_service(rawbytes) diff --git a/tailbone/api/master2.py b/tailbone/api/master2.py new file mode 100644 index 00000000..4a5abb3e --- /dev/null +++ b/tailbone/api/master2.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Master View (v2) +""" + +from __future__ import unicode_literals, absolute_import + +import warnings + +from tailbone.api import APIMasterView + + +class APIMasterView2(APIMasterView): + """ + Base class for data model REST API views. + """ + + def __init__(self, request, context=None): + warnings.warn("APIMasterView2 class is deprecated; please use " + "APIMasterView instead", + DeprecationWarning, stacklevel=2) + super(APIMasterView2, self).__init__(request, context=context) diff --git a/tailbone/api/people.py b/tailbone/api/people.py new file mode 100644 index 00000000..f7c08dfa --- /dev/null +++ b/tailbone/api/people.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Person Views +""" + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class PersonView(APIMasterView): + """ + API views for Person data + """ + model_class = model.Person + permission_prefix = 'people' + collection_url_prefix = '/people' + object_url_prefix = '/person' + + def normalize(self, person): + return { + 'uuid': person.uuid, + '_str': str(person), + 'first_name': person.first_name, + 'last_name': person.last_name, + 'display_name': person.display_name, + } + + +def defaults(config, **kwargs): + base = globals() + + PersonView = kwargs.get('PersonView', base['PersonView']) + PersonView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/products.py b/tailbone/api/products.py new file mode 100644 index 00000000..3f29ff54 --- /dev/null +++ b/tailbone/api/products.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Product Views +""" + +import logging + +import sqlalchemy as sa +from sqlalchemy import orm + +from cornice import Service + +from rattail.db import model + +from tailbone.api import APIMasterView + + +log = logging.getLogger(__name__) + + +class ProductView(APIMasterView): + """ + API views for Product data + """ + model_class = model.Product + collection_url_prefix = '/products' + object_url_prefix = '/product' + supports_autocomplete = True + + def __init__(self, request, context=None): + super(ProductView, self).__init__(request, context=context) + app = self.get_rattail_app() + self.products_handler = app.get_products_handler() + + def normalize(self, product): + + # get what we can from handler + data = self.products_handler.normalize_product(product, fields=[ + 'brand_name', + 'full_description', + 'department_name', + 'unit_price_display', + 'sale_price', + 'sale_price_display', + 'sale_ends', + 'sale_ends_display', + 'tpr_price', + 'tpr_price_display', + 'tpr_ends', + 'tpr_ends_display', + 'current_price', + 'current_price_display', + 'current_ends', + 'current_ends_display', + 'vendor_name', + 'costs', + 'image_url', + ]) + + # but must supplement + cost = product.cost + data.update({ + 'upc': str(product.upc), + 'scancode': product.scancode, + 'item_id': product.item_id, + 'item_type': product.item_type, + 'status_code': product.status_code, + 'default_unit_cost': cost.unit_cost if cost else None, + 'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None, + }) + + return data + + def make_autocomplete_query(self, term): + query = self.Session.query(model.Product)\ + .outerjoin(model.Brand)\ + .filter(sa.or_( + model.Brand.name.ilike('%{}%'.format(term)), + model.Product.description.ilike('%{}%'.format(term)))) + + if not self.request.has_perm('products.view_deleted'): + query = query.filter(model.Product.deleted == False) + + query = query.order_by(model.Brand.name, + model.Product.description)\ + .options(orm.joinedload(model.Product.brand)) + return query + + def autocomplete_display(self, product): + return product.full_description + + def quick_lookup(self): + """ + View for handling "quick lookup" user input, for index page. + """ + data = self.request.GET + entry = data['entry'] + + product = self.products_handler.locate_product_for_entry(self.Session(), + entry) + if not product: + return {'error': "Product not found"} + + return {'ok': True, + 'product': self.normalize(product)} + + def label_profiles(self): + """ + Returns the set of label profiles available for use with + printing label for product. + """ + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + + profiles = [] + for profile in label_handler.get_label_profiles(self.Session()): + profiles.append({ + 'uuid': profile.uuid, + 'description': profile.description, + }) + + return {'label_profiles': profiles} + + def print_labels(self): + app = self.get_rattail_app() + label_handler = app.get_label_handler() + model = self.model + data = self.request.json_body + + uuid = data.get('label_profile_uuid') + profile = self.Session.get(model.LabelProfile, uuid) if uuid else None + if not profile: + return {'error': "Label profile not found"} + + uuid = data.get('product_uuid') + product = self.Session.get(model.Product, uuid) if uuid else None + if not product: + return {'error': "Product not found"} + + try: + quantity = int(data.get('quantity')) + except: + return {'error': "Quantity must be integer"} + + printer = label_handler.get_printer(profile) + if not printer: + return {'error': "Couldn't get printer from label profile"} + + try: + printer.print_labels([({'product': product}, quantity)]) + except Exception as error: + log.warning("error occurred while printing labels", exc_info=True) + return {'error': str(error)} + + return {'ok': True} + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._product_defaults(config) + + @classmethod + def _product_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + + # quick lookup + quick_lookup = Service(name='{}.quick_lookup'.format(route_prefix), + path='{}/quick-lookup'.format(collection_url_prefix)) + quick_lookup.add_view('GET', 'quick_lookup', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(quick_lookup) + + # label profiles + label_profiles = Service(name=f'{route_prefix}.label_profiles', + path=f'{collection_url_prefix}/label-profiles') + label_profiles.add_view('GET', 'label_profiles', klass=cls, + permission=f'{permission_prefix}.print_labels') + config.add_cornice_service(label_profiles) + + # print labels + print_labels = Service(name='{}.print_labels'.format(route_prefix), + path='{}/print-labels'.format(collection_url_prefix)) + print_labels.add_view('POST', 'print_labels', klass=cls, + permission='{}.print_labels'.format(permission_prefix)) + config.add_cornice_service(print_labels) + + +def defaults(config, **kwargs): + base = globals() + + ProductView = kwargs.get('ProductView', base['ProductView']) + ProductView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py new file mode 100644 index 00000000..467c8a0d --- /dev/null +++ b/tailbone/api/upgrades.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Upgrade Views +""" + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class UpgradeView(APIMasterView): + """ + REST API views for Upgrade model. + """ + model_class = model.Upgrade + collection_url_prefix = '/upgrades' + object_url_prefix = '/upgrades' + + def normalize(self, upgrade): + data = { + 'created': upgrade.created.isoformat(), + 'description': upgrade.description, + 'enabled': upgrade.enabled, + 'executed': upgrade.executed.isoformat() if upgrade.executed else None, + # 'executed_by': + } + if upgrade.status_code is None: + data['status_code'] = None + else: + data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code, + str(upgrade.status_code)) + return data + + +def defaults(config, **kwargs): + base = globals() + + UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) + UpgradeView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/users.py b/tailbone/api/users.py new file mode 100644 index 00000000..a6bcad57 --- /dev/null +++ b/tailbone/api/users.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - User Views +""" + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class UserView(APIMasterView): + """ + API views for User data + """ + model_class = model.User + collection_url_prefix = '/users' + object_url_prefix = '/user' + + def normalize(self, user): + return { + 'uuid': user.uuid, + 'username': user.username, + 'person_display_name': (user.person.display_name or '') if user.person else '', + 'active': user.active, + } + + def interpret_sortcol(self, order_by): + if order_by == 'person_display_name': + return self.sortcol('Person', 'display_name') + return self.sortcol(order_by) + + def join_for_sort_model(self, query, model_name): + if model_name == 'Person': + query = query.outerjoin(model.Person) + return query + + def update_object(self, user, data): + # TODO: should ensure prevent_password_change is respected + return super(UserView, self).update_object(user, data) + + +def defaults(config, **kwargs): + base = globals() + + UserView = kwargs.get('UserView', base['UserView']) + UserView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py new file mode 100644 index 00000000..64311b1b --- /dev/null +++ b/tailbone/api/vendors.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Vendor Views +""" + +from rattail.db import model + +from tailbone.api import APIMasterView + + +class VendorView(APIMasterView): + + model_class = model.Vendor + collection_url_prefix = '/vendors' + object_url_prefix = '/vendor' + supports_autocomplete = True + autocomplete_fieldname = 'name' + + def normalize(self, vendor): + return { + 'uuid': vendor.uuid, + '_str': str(vendor), + 'id': vendor.id, + 'name': vendor.name, + } + + +def defaults(config, **kwargs): + base = globals() + + VendorView = kwargs.get('VendorView', base['VendorView']) + VendorView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py new file mode 100644 index 00000000..19def6c4 --- /dev/null +++ b/tailbone/api/workorders.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Web API - Work Order Views +""" + +import datetime + +from rattail.db.model import WorkOrder + +from cornice import Service + +from tailbone.api import APIMasterView + + +class WorkOrderView(APIMasterView): + + model_class = WorkOrder + collection_url_prefix = '/workorders' + object_url_prefix = '/workorder' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + app = self.get_rattail_app() + self.workorder_handler = app.get_workorder_handler() + + def normalize(self, workorder): + data = super().normalize(workorder) + data.update({ + 'customer_name': workorder.customer.name, + 'status_label': self.enum.WORKORDER_STATUS[workorder.status_code], + 'date_submitted': str(workorder.date_submitted or ''), + 'date_received': str(workorder.date_received or ''), + 'date_released': str(workorder.date_released or ''), + 'date_delivered': str(workorder.date_delivered or ''), + }) + return data + + def create_object(self, data): + + # invoke the handler instead of normal API CRUD logic + workorder = self.workorder_handler.make_workorder(self.Session(), **data) + return workorder + + def update_object(self, workorder, data): + date_fields = [ + 'date_submitted', + 'date_received', + 'date_released', + 'date_delivered', + ] + + # coerce date field values to proper datetime.date objects + for field in date_fields: + if field in data: + if data[field] == '': + data[field] = None + elif not isinstance(data[field], datetime.date): + date = datetime.datetime.strptime(data[field], '%Y-%m-%d').date() + data[field] = date + + # coerce status code value to proper integer + if 'status_code' in data: + data['status_code'] = int(data['status_code']) + + return super().update_object(workorder, data) + + def status_codes(self): + """ + Retrieve all info about possible work order status codes. + """ + return self.workorder_handler.status_codes() + + def receive(self): + """ + Sets work order status to "received". + """ + workorder = self.get_object() + self.workorder_handler.receive(workorder) + self.Session.flush() + return self.normalize(workorder) + + def await_estimate(self): + """ + Sets work order status to "awaiting estimate confirmation". + """ + workorder = self.get_object() + self.workorder_handler.await_estimate(workorder) + self.Session.flush() + return self.normalize(workorder) + + def await_parts(self): + """ + Sets work order status to "awaiting parts". + """ + workorder = self.get_object() + self.workorder_handler.await_parts(workorder) + self.Session.flush() + return self.normalize(workorder) + + def work_on_it(self): + """ + Sets work order status to "working on it". + """ + workorder = self.get_object() + self.workorder_handler.work_on_it(workorder) + self.Session.flush() + return self.normalize(workorder) + + def release(self): + """ + Sets work order status to "released". + """ + workorder = self.get_object() + self.workorder_handler.release(workorder) + self.Session.flush() + return self.normalize(workorder) + + def deliver(self): + """ + Sets work order status to "delivered". + """ + workorder = self.get_object() + self.workorder_handler.deliver(workorder) + self.Session.flush() + return self.normalize(workorder) + + def cancel(self): + """ + Sets work order status to "canceled". + """ + workorder = self.get_object() + self.workorder_handler.cancel(workorder) + self.Session.flush() + return self.normalize(workorder) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + cls._workorder_defaults(config) + + @classmethod + def _workorder_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + collection_url_prefix = cls.get_collection_url_prefix() + object_url_prefix = cls.get_object_url_prefix() + + # status codes + status_codes = Service(name='{}.status_codes'.format(route_prefix), + path='{}/status-codes'.format(collection_url_prefix)) + status_codes.add_view('GET', 'status_codes', klass=cls, + permission='{}.list'.format(permission_prefix)) + config.add_cornice_service(status_codes) + + # receive + receive = Service(name='{}.receive'.format(route_prefix), + path='{}/{{uuid}}/receive'.format(object_url_prefix)) + receive.add_view('POST', 'receive', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(receive) + + # await estimate confirmation + await_estimate = Service(name='{}.await_estimate'.format(route_prefix), + path='{}/{{uuid}}/await-estimate'.format(object_url_prefix)) + await_estimate.add_view('POST', 'await_estimate', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(await_estimate) + + # await parts + await_parts = Service(name='{}.await_parts'.format(route_prefix), + path='{}/{{uuid}}/await-parts'.format(object_url_prefix)) + await_parts.add_view('POST', 'await_parts', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(await_parts) + + # work on it + work_on_it = Service(name='{}.work_on_it'.format(route_prefix), + path='{}/{{uuid}}/work-on-it'.format(object_url_prefix)) + work_on_it.add_view('POST', 'work_on_it', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(work_on_it) + + # release + release = Service(name='{}.release'.format(route_prefix), + path='{}/{{uuid}}/release'.format(object_url_prefix)) + release.add_view('POST', 'release', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(release) + + # deliver + deliver = Service(name='{}.deliver'.format(route_prefix), + path='{}/{{uuid}}/deliver'.format(object_url_prefix)) + deliver.add_view('POST', 'deliver', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(deliver) + + # cancel + cancel = Service(name='{}.cancel'.format(route_prefix), + path='{}/{{uuid}}/cancel'.format(object_url_prefix)) + cancel.add_view('POST', 'cancel', klass=cls, + permission='{}.edit'.format(permission_prefix)) + config.add_cornice_service(cancel) + + +def defaults(config, **kwargs): + base = globals() + + WorkOrderView = kwargs.get('WorkOrderView', base['WorkOrderView']) + WorkOrderView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tailbone/app.py b/tailbone/app.py new file mode 100644 index 00000000..d2d0c5ef --- /dev/null +++ b/tailbone/app.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Application Entry Point +""" + +import os + +from sqlalchemy.orm import sessionmaker, scoped_session + +from wuttjamaican.util import parse_list + +from rattail.config import make_config +from rattail.exceptions import ConfigurationError + +from pyramid.config import Configurator +from zope.sqlalchemy import register + +import tailbone.db +from tailbone.auth import TailboneSecurityPolicy +from tailbone.config import csrf_token_name, csrf_header_name +from tailbone.util import get_effective_theme, get_theme_template_path +from tailbone.providers import get_all_providers + + +def make_rattail_config(settings): + """ + Make a Rattail config object from the given settings. + """ + rattail_config = settings.get('rattail_config') + if not rattail_config: + + # initialize rattail config and embed in settings dict, to make + # available for web requests later + path = settings.get('rattail.config') + if not path or not os.path.exists(path): + raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config " + "to the path of your config file. Lame, but necessary.") + rattail_config = make_config(path) + settings['rattail_config'] = rattail_config + + # 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, 'appdb_engine'): + tailbone.db.Session.configure(bind=rattail_config.appdb_engine) + if hasattr(rattail_config, 'trainwreck_engine'): + tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) + if hasattr(rattail_config, 'tempmon_engine'): + tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine) + + # maybe set "future" behavior for SQLAlchemy + if rattail_config.getbool('rattail.db', 'sqlalchemy_future_mode', usedb=False): + tailbone.db.Session.configure(future=True) + + # create session wrappers for each "extra" Trainwreck engine + for key, engine in rattail_config.trainwreck_engines.items(): + if key != 'default': + Session = scoped_session(sessionmaker(bind=engine)) + register(Session) + tailbone.db.ExtraTrainwreckSessions[key] = Session + + # Make sure rattail config object uses our scoped session, to avoid + # unnecessary connections (and pooling limits). + rattail_config._session_factory = lambda: (tailbone.db.Session(), False) + + return rattail_config + + +def provide_postgresql_settings(settings): + """ + Add some PostgreSQL-specific settings to the app config. Specifically, + this enables retrying transactions a second time, in an attempt to + gracefully handle database restarts. + """ + try: + import pyramid_retry + except ImportError: + settings.setdefault('tm.attempts', 2) + else: + settings.setdefault('retry.attempts', 2) + + +class Root(dict): + """ + Root factory for Pyramid. This is necessary to make the current request + available to the authorization policy object, which needs it to check if + the current request "is root". + """ + + def __init__(self, request): + self.request = request + + +def make_pyramid_config(settings, configure_csrf=True): + """ + Make a Pyramid config object from the given settings. + """ + rattail_config = settings['rattail_config'] + + config = settings.pop('pyramid_config', None) + if config: + config.set_root_factory(Root) + else: + + # declare this web app of the "classic" variety + settings.setdefault('tailbone.classic', 'true') + + # we want the new themes feature! + establish_theme(settings) + + settings.setdefault('fanstatic.versioning', 'true') + settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') + config = Configurator(settings=settings, root_factory=Root) + + # add rattail config directly to registry, for access throughout the app + config.registry['rattail_config'] = rattail_config + + # configure user authorization / authentication + config.set_security_policy(TailboneSecurityPolicy()) + + # maybe require CSRF token protection + if configure_csrf: + config.set_default_csrf_options(require_csrf=True, + token=csrf_token_name(rattail_config), + header=csrf_header_name(rattail_config)) + + # Bring in some Pyramid goodies. + config.include('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: + import pyramid_retry + except ImportError: + pass + else: + config.include('pyramid_retry') + + # fetch all tailbone providers + providers = get_all_providers(rattail_config) + for provider in providers.values(): + + # configure DB sessions associated with transaction manager + provider.configure_db_sessions(rattail_config, config) + + # add any static includes + includes = provider.get_static_includes() + if includes: + for spec in includes: + config.include(spec) + + # add some permissions magic + config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + # TODO: deprecate / remove these + config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') + + # and some similar magic for certain master views + config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') + config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page') + config.add_directive('add_tailbone_model_view', 'tailbone.app.add_model_view') + config.add_directive('add_tailbone_view_supplement', 'tailbone.app.add_view_supplement') + + config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket') + + return config + + +def add_websocket(config, name, view, attr=None): + """ + Register a websocket entry point for the app. + """ + def action(): + rattail_config = config.registry.settings['rattail_config'] + rattail_app = rattail_config.get_app() + + if isinstance(view, str): + view_callable = rattail_app.load_object(view) + else: + view_callable = view + view_callable = view_callable(config) + if attr: + view_callable = getattr(view_callable, attr) + + # register route + path = '/ws/{}'.format(name) + route_name = 'ws.{}'.format(name) + config.add_route(route_name, path, static=True) + + # register view callable + websockets = config.registry.setdefault('tailbone_websockets', {}) + websockets[path] = view_callable + + config.action('tailbone-add-websocket-{}'.format(name), action, + # nb. since this action adds routes, it must happen + # sooner in the order than it normally would, hence + # we declare that + order=-20) + + +def add_index_page(config, route_name, label, permission): + """ + Register a config page for the app. + """ + def action(): + pages = config.get_settings().get('tailbone_index_pages', []) + pages.append({'label': label, 'route': route_name, + 'permission': permission}) + config.add_settings({'tailbone_index_pages': pages}) + config.action(None, action) + + +def add_config_page(config, route_name, label, permission): + """ + Register a config page for the app. + """ + def action(): + pages = config.get_settings().get('tailbone_config_pages', []) + pages.append({'label': label, 'route': route_name, + 'permission': permission}) + config.add_settings({'tailbone_config_pages': pages}) + config.action(None, action) + + +def add_model_view(config, model_name, label, route_prefix, permission_prefix): + """ + Register a model view for the app. + """ + def action(): + all_views = config.get_settings().get('tailbone_model_views', {}) + + model_views = all_views.setdefault(model_name, []) + model_views.append({ + 'label': label, + 'route_prefix': route_prefix, + 'permission_prefix': permission_prefix, + }) + + config.add_settings({'tailbone_model_views': all_views}) + + config.action(None, action) + + +def add_view_supplement(config, route_prefix, cls): + """ + Register a master view supplement for the app. + """ + def action(): + supplements = config.get_settings().get('tailbone_view_supplements', {}) + supplements.setdefault(route_prefix, []).append(cls) + config.add_settings({'tailbone_view_supplements': supplements}) + config.action(None, action) + + +def establish_theme(settings): + rattail_config = settings['rattail_config'] + + theme = get_effective_theme(rattail_config) + settings['tailbone.theme'] = theme + + directories = settings['mako.directories'] + if isinstance(directories, str): + directories = parse_list(directories) + + path = get_theme_template_path(rattail_config) + directories.insert(0, path) + settings['mako.directories'] = directories + + +def configure_postgresql(pyramid_config): + """ + Add some PostgreSQL-specific tweaks to the final app config. Specifically, + adds the tween necessary for graceful handling of database restarts. + """ + pyramid_config.add_tween('tailbone.tweens.sqlerror_tween_factory', + under='pyramid_tm.tm_tween_factory') + + +def main(global_config, **settings): + """ + This function returns a Pyramid WSGI application. + """ + settings.setdefault('mako.directories', ['tailbone:templates', + 'wuttaweb:templates']) + rattail_config = make_rattail_config(settings) + pyramid_config = make_pyramid_config(settings) + pyramid_config.include('tailbone') + return pyramid_config.make_wsgi_app() diff --git a/tailbone/asgi.py b/tailbone/asgi.py new file mode 100644 index 00000000..1afbe12a --- /dev/null +++ b/tailbone/asgi.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +ASGI App Utilities +""" + +import os +import configparser +import logging + +from rattail.util import load_object + +from asgiref.wsgi import WsgiToAsgi + + +log = logging.getLogger(__name__) + + +class TailboneWsgiToAsgi(WsgiToAsgi): + """ + Custom WSGI -> ASGI wrapper, to add routing for websockets. + """ + + async def __call__(self, scope, *args, **kwargs): + protocol = scope['type'] + path = scope['path'] + + # strip off the root path, if non-empty. needed for serving + # under /poser or anything other than true site root + root_path = scope['root_path'] + if root_path and path.startswith(root_path): + path = path[len(root_path):] + + if protocol == 'websocket': + websockets = self.wsgi_application.registry.get( + 'tailbone_websockets', {}) + if path in websockets: + await websockets[path](scope, *args, **kwargs) + + try: + await super().__call__(scope, *args, **kwargs) + except ValueError as e: + # The developer may wish to improve handling of this exception. + # See https://github.com/Pylons/pyramid_cookbook/issues/225 and + # https://asgi.readthedocs.io/en/latest/specs/www.html#websocket + pass + except Exception as e: + raise e + + +def make_asgi_app(main_app=None): + """ + This function returns an ASGI application. + """ + path = os.environ.get('TAILBONE_ASGI_CONFIG') + if not path: + raise RuntimeError("You must define TAILBONE_ASGI_CONFIG env variable.") + + # make a config parser good enough to load pyramid settings + configdir = os.path.dirname(path) + parser = configparser.ConfigParser(defaults={'__file__': path, + 'here': configdir}) + + # read the config file + parser.read(path) + + # parse the settings needed for pyramid app + settings = dict(parser.items('app:main')) + + if isinstance(main_app, str): + make_wsgi_app = load_object(main_app) + elif callable(main_app): + make_wsgi_app = main_app + else: + if main_app: + log.warning("specified main app of unknown type: %s", main_app) + make_wsgi_app = load_object('tailbone.app:main') + + # construct a pyramid app "per usual" + app = make_wsgi_app({}, **settings) + + # then wrap it with ASGI + return TailboneWsgiToAsgi(app) + + +def asgi_main(): + """ + This function returns an ASGI application. + """ + return make_asgi_app() diff --git a/tailbone/auth.py b/tailbone/auth.py new file mode 100644 index 00000000..95bf90ba --- /dev/null +++ b/tailbone/auth.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Authentication & Authorization +""" + +import logging +import re + +from wuttjamaican.util import UNSPECIFIED + +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=UNSPECIFIED): + """ + Perform the steps necessary to login the given user. Note that this + returns a ``headers`` dict which you should pass to the redirect. + """ + config = request.rattail_config + app = config.get_app() + user.record_event(app.enum.USER_EVENT_LOGIN) + headers = remember(request, user.uuid) + if timeout is UNSPECIFIED: + timeout = session_timeout_for_user(config, user) + log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) + set_session_timeout(request, timeout) + return headers + + +def 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(app.enum.USER_EVENT_LOGOUT) + request.session.delete() + request.session.invalidate() + headers = forget(request) + return headers + + +def session_timeout_for_user(config, user): + """ + Returns the "max" session timeout for the user, according to roles + """ + app = config.get_app() + auth = app.get_auth_handler() + + authenticated = auth.get_role_authenticated(Session()) + roles = user.roles + [authenticated] + timeouts = [role.session_timeout for role in roles + if role.session_timeout is not None] + + if timeouts and 0 not in timeouts: + return max(timeouts) + + +def set_session_timeout(request, timeout): + """ + Set the server-side session timeout to the given value. + """ + request.session['_timeout'] = timeout or None + + +class TailboneSecurityPolicy(WuttaSecurityPolicy): + + def __init__(self, db_session=None, api_mode=False, **kwargs): + kwargs['db_session'] = db_session or Session() + super().__init__(**kwargs) + self.api_mode = api_mode + + def load_identity(self, request): + config = request.registry.settings.get('rattail_config') + app = config.get_app() + user = None + + if self.api_mode: + + # determine/load user from header token if present + credentials = request.headers.get('Authorization') + if credentials: + match = re.match(r'^Bearer (\S+)$', credentials) + if match: + token = match.group(1) + auth = app.get_auth_handler() + user = auth.authenticate_user_token(self.db_session, token) + + if not user: + + # fetch user uuid from current session + uuid = self.session_helper.authenticated_userid(request) + if not uuid: + return + + # fetch user object from db + model = app.model + user = self.db_session.get(model.User, uuid) + if not user: + return + + # this user is responsible for data changes in current request + self.db_session.set_continuum_user(user) + return user diff --git a/tailbone/beaker.py b/tailbone/beaker.py new file mode 100644 index 00000000..25a450df --- /dev/null +++ b/tailbone/beaker.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Custom sessions, based on Beaker + +Note that most of the code for this module was copied from the beaker and +pyramid_beaker projects. +""" + +import time +from pkg_resources import parse_version + +from rattail.util import get_pkg_version + +import beaker +from beaker.session import Session +from beaker.util import coerce_session_params +from pyramid.settings import asbool +from pyramid_beaker import BeakerSessionFactoryConfig, set_cache_regions_from_settings + + +class TailboneSession(Session): + """ + Custom session class for Beaker, which overrides load() to add per-request + session timeout support. + """ + + def load(self): + "Loads the data from this session from persistent storage" + + # are we using older version of beaker? + old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12') + + self.namespace = self.namespace_class(self.id, + data_dir=self.data_dir, + digest_filenames=False, + **self.namespace_args) + now = time.time() + if self.use_cookies: + self.request['set_cookie'] = True + + self.namespace.acquire_read_lock() + timed_out = False + try: + self.clear() + try: + session_data = self.namespace['session'] + + if old_beaker: + if (session_data is not None and self.encrypt_key): + session_data = self._decrypt_data(session_data) + else: # beaker >= 1.12 + if session_data is not None: + session_data = self._decrypt_data(session_data) + + # Memcached always returns a key, its None when its not + # present + if session_data is None: + session_data = { + '_creation_time': now, + '_accessed_time': now + } + self.is_new = True + except (KeyError, TypeError): + session_data = { + '_creation_time': now, + '_accessed_time': now + } + self.is_new = True + + if session_data is None or len(session_data) == 0: + session_data = { + '_creation_time': now, + '_accessed_time': now + } + self.is_new = True + + # TODO: sure would be nice if we could get this little bit of logic + # into the upstream Beaker package, as that would avoid the need + # for this module entirely... + timeout = session_data.get('_timeout', self.timeout) + if timeout is not None and \ + '_accessed_time' in session_data and \ + now - session_data['_accessed_time'] > timeout: + timed_out = True + else: + # Properly set the last_accessed time, which is different + # than the *currently* _accessed_time + if self.is_new or '_accessed_time' not in session_data: + self.last_accessed = None + else: + self.last_accessed = session_data['_accessed_time'] + + # Update the current _accessed_time + session_data['_accessed_time'] = now + + self.update(session_data) + self.accessed_dict = session_data.copy() + finally: + self.namespace.release_read_lock() + if timed_out: + self.invalidate() + + +def session_factory_from_settings(settings): + """ Return a Pyramid session factory using Beaker session settings + supplied from a Paste configuration file""" + prefixes = ('session.', 'beaker.session.') + options = {} + + # Pull out any config args meant for beaker session. if there are any + for k, v in settings.items(): + for prefix in prefixes: + if k.startswith(prefix): + option_name = k[len(prefix):] + if option_name == 'cookie_on_exception': + v = asbool(v) + options[option_name] = v + + options = coerce_session_params(options) + options['session_class'] = TailboneSession + return BeakerSessionFactoryConfig(**options) + + +def includeme(config): + session_factory = session_factory_from_settings(config.registry.settings) + config.set_session_factory(session_factory) + set_cache_regions_from_settings(config.registry.settings) diff --git a/tailbone/cleanup.py b/tailbone/cleanup.py new file mode 100644 index 00000000..0ed5d026 --- /dev/null +++ b/tailbone/cleanup.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2022 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +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 new file mode 100644 index 00000000..8392ba0a --- /dev/null +++ b/tailbone/config.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Rattail config extension for Tailbone +""" + +import warnings + +from wuttjamaican.conf import WuttaConfigExtension + +from rattail.db.config import configure_session + +from tailbone.db import Session + + +class ConfigExtension(WuttaConfigExtension): + """ + Rattail config extension for Tailbone. Does the following: + + * Adds the rattail config object to the constructor kwargs for the + underlying Session factory. + + * Configures the main Tailbone database session so that it records + changes, if the config file so dictates. + """ + key = 'tailbone' + + def configure(self, config): + Session.configure(rattail_config=config) + configure_session(config, Session) + + # provide default theme selection + config.setdefault('tailbone', 'themes.keys', 'default, butterball') + config.setdefault('tailbone', 'themes.expose_picker', 'true') + + # override oruga detection + config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga') + + +def csrf_token_name(config): + return config.get('tailbone', 'csrf_token_name', default='_csrf') + + +def csrf_header_name(config): + return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN') + + +def global_help_url(config): + return config.get('tailbone', 'global_help_url') + + +def protected_usernames(config): + return config.getlist('tailbone', 'protected_usernames') + + +def should_expose_websockets(config): + return config.getbool('tailbone', 'expose_websockets', + usedb=False, default=False) diff --git a/tailbone/db.py b/tailbone/db.py new file mode 100644 index 00000000..8b37f399 --- /dev/null +++ b/tailbone/db.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Database sessions etc. +""" + +import sqlalchemy as sa +from zope.sqlalchemy import datamanager +import sqlalchemy_continuum as continuum +from sqlalchemy.orm import sessionmaker, scoped_session + +from rattail.db import SessionBase +from rattail.db.continuum import versioning_manager + + +Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, expire_on_commit=False)) + +# not necessarily used, but here if you need it +TempmonSession = scoped_session(sessionmaker()) +TrainwreckSession = scoped_session(sessionmaker()) + +# empty dict for now, this must populated on app startup (if needed) +ExtraTrainwreckSessions = {} + + +class TailboneSessionDataManager(datamanager.SessionDataManager): + """ + Integrate a top level sqlalchemy session transaction into a zope + transaction + + One phase variant. + + .. note:: + + 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 + + # Force creation of Continuum versions for current session. + uow = versioning_manager.unit_of_work(self.session) + uow.make_versions(self.session) + + self.tx.commit() + 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. + + 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 + + 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. + + .. note:: + + 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 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 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 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) + + def after_attach(self, session, instance): + """ """ + 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. + + 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. + + .. note:: + + 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 = ZopeTransactionEvents( + initial_state=initial_state, + transaction_manager=transaction_manager, + keep_session=keep_session, + ) + + event.listen(session, "after_begin", ext.after_begin) + event.listen(session, "after_attach", ext.after_attach) + event.listen(session, "after_flush", ext.after_flush) + event.listen(session, "after_bulk_update", ext.after_bulk_update) + 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) +register(TrainwreckSession) diff --git a/tailbone/diffs.py b/tailbone/diffs.py new file mode 100644 index 00000000..2e582b15 --- /dev/null +++ b/tailbone/diffs.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tools for displaying data diffs +""" + +import sqlalchemy as sa +import sqlalchemy_continuum as continuum + +from pyramid.renderers import render +from webhelpers2.html import HTML + + +class Diff(object): + """ + Core diff class. In sore need of documentation. + + You must provide the old and new data sets, and the set of + relevant fields as well, if they cannot be easily introspected. + + :param old_data: Dict of "old" data values. + + :param new_data: Dict of "old" data values. + + :param fields: Sequence of relevant field names. Note that + both data dicts are expected to have keys which match these + field names. If you do not specify the fields then they + will (hopefully) be introspected from the old or new data + sets; however this will not work if they are both empty. + + :param monospace: If true, this flag will cause the value + columns to be rendered in monospace font. This is assumed + to be helpful when comparing "raw" data values which are + shown as e.g. ``repr(val)``. + + :param enums: Optional dict of enums for use when displaying field + values. If specified, keys should be field names and values + should be enum dicts. + """ + + def __init__(self, old_data, new_data, columns=None, fields=None, enums=None, + render_field=None, render_value=None, nature='dirty', + monospace=False, extra_row_attrs=None): + self.old_data = old_data + self.new_data = new_data + self.columns = columns or ["field name", "old value", "new value"] + self.fields = fields or self.make_fields() + self.enums = enums or {} + self._render_field = render_field or self.render_field_default + self.render_value = render_value or self.render_value_default + self.nature = nature + self.monospace = monospace + self.extra_row_attrs = extra_row_attrs + + def make_fields(self): + return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower()) + + def old_value(self, field): + return self.old_data.get(field) + + def new_value(self, field): + return self.new_data.get(field) + + def values_differ(self, field): + return self.new_value(field) != self.old_value(field) + + def render_html(self, template='/diff.mako', **kwargs): + context = kwargs + context['diff'] = self + return HTML.literal(render(template, context)) + + def get_row_attrs(self, field): + """ + Returns a *rendered* set of extra attributes for the ```` element + for the given field. May be an empty string, or a snippet of HTML + attribute syntax, e.g.: + + .. code-block:: none + + class="diff" foo="bar" + + If you wish to supply additional attributes, please define + :attr:`extra_row_attrs`, which can be either a static dict, or a + callable returning a dict. + """ + attrs = {} + if self.values_differ(field): + attrs['class'] = 'diff' + + if self.extra_row_attrs: + if callable(self.extra_row_attrs): + attrs.update(self.extra_row_attrs(field, attrs)) + else: + attrs.update(self.extra_row_attrs) + + return HTML.render_attrs(attrs) + + def render_field(self, field): + return self._render_field(field, self) + + def render_field_default(self, field, diff): + return field + + def render_value_default(self, field, value): + return repr(value) + + def render_old_value(self, field): + value = self.old_value(field) + return self.render_value(field, value) + + def render_new_value(self, field): + value = self.new_value(field) + return self.render_value(field, value) + + +class VersionDiff(Diff): + """ + Special diff class, for use with version history views. Note that + while based on :class:`Diff`, this class uses a different + signature for the constructor. + + :param version: Reference to a Continuum version record (object). + + :param \*args: Typical usage will not require positional args + beyond the ``version`` param, in which case ``old_data`` and + ``new_data`` params will be auto-determined based on the + ``version``. But if you specify positional args then nothing + automatic is done, they are passed as-is to the parent + :class:`Diff` constructor. + + :param \*\*kwargs: Remaining kwargs are passed as-is to the + :class:`Diff` constructor. + """ + + def __init__(self, version, *args, **kwargs): + self.version = version + self.mapper = sa.inspect(continuum.parent_class(type(self.version))) + self.version_mapper = sa.inspect(type(self.version)) + self.title = kwargs.pop('title', None) + + if 'nature' not in kwargs: + if version.previous and version.operation_type == continuum.Operation.DELETE: + kwargs['nature'] = 'deleted' + elif version.previous: + kwargs['nature'] = 'dirty' + else: + kwargs['nature'] = 'new' + + if 'fields' not in kwargs: + kwargs['fields'] = self.get_default_fields() + + if not args: + old_data = {} + new_data = {} + for field in kwargs['fields']: + if version.previous: + old_data[field] = getattr(version.previous, field) + new_data[field] = getattr(version, field) + args = (old_data, new_data) + + super().__init__(*args, **kwargs) + + def get_default_fields(self): + fields = sorted(self.version_mapper.columns.keys()) + + unwanted = [ + 'transaction_id', + 'end_transaction_id', + 'operation_type', + ] + + return [field for field in fields + if field not in unwanted] + + def render_version_value(self, field, value, version): + """ + Render the cell value text for the given version/field info. + + Note that this method is used to render both sides of the diff + (before and after values). + + :param field: Name of the field, as string. + + :param value: Raw value for the field, as obtained from ``version``. + + :param version: Reference to the Continuum version object. + + :returns: Rendered text as string, or ``None``. + """ + text = HTML.tag('span', c=[repr(value)], + style='font-family: monospace;') + + # assume the enum display is all we need, if enum exists for the field + if field in self.enums: + + # but skip the enum display if None + display = self.enums[field].get(value) + if display is None and value is None: + return text + + # otherwise show enum display to the right of raw value + display = self.enums[field].get(value, str(value)) + return HTML.tag('span', c=[ + text, + HTML.tag('span', c=[display], + style='margin-left: 2rem; font-style: italic; font-weight: bold;'), + ]) + + # next we look for a relationship and may render the foreign object + for prop in self.mapper.relationships: + if prop.uselist: + continue + + for col in prop.local_columns: + if col.name != field: + continue + + if not hasattr(version, prop.key): + continue + + if col in self.mapper.primary_key: + continue + + ref = getattr(version, prop.key) + if ref: + ref = getattr(ref, 'version_parent', None) + if ref: + return HTML.tag('span', c=[ + text, + HTML.tag('span', c=[str(ref)], + style='margin-left: 2rem; font-style: italic; font-weight: bold;'), + ]) + + return text + + def render_old_value(self, field): + if self.nature == 'new': + return '' + value = self.old_value(field) + return self.render_version_value(field, value, self.version.previous) + + def render_new_value(self, field): + if self.nature == 'deleted': + return '' + value = self.new_value(field) + return self.render_version_value(field, value, self.version) + + def as_struct(self): + values = {} + for field in self.fields: + values[field] = {'before': self.render_old_value(field), + 'after': self.render_new_value(field)} + + operation = None + if self.version.operation_type == continuum.Operation.INSERT: + operation = 'INSERT' + elif self.version.operation_type == continuum.Operation.UPDATE: + operation = 'UPDATE' + elif self.version.operation_type == continuum.Operation.DELETE: + operation = 'DELETE' + else: + operation = self.version.operation_type + + return { + 'key': id(self.version), + 'model_title': self.title, + 'operation': operation, + 'diff_class': self.nature, + 'fields': self.fields, + 'values': values, + } diff --git a/tailbone/exceptions.py b/tailbone/exceptions.py new file mode 100644 index 00000000..3468562a --- /dev/null +++ b/tailbone/exceptions.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Tailbone Exceptions +""" + +from rattail.exceptions import RattailError + + +class TailboneError(RattailError): + """ + Base class for all Tailbone exceptions. + """ + + +class TailboneJSONFieldError(TailboneError): + """ + Error raised when JSON serialization of a form field results in an error. + This is just a simple wrapper, to make the error message more helpful for + the developer. + """ + + def __init__(self, field, error): + self.field = field + self.error = error + + def __str__(self): + return ("Failed to serialize field '{}' as JSON! " + "Original error was: {}".format(self.field, self.error)) diff --git a/tailbone/forms/__init__.py b/tailbone/forms/__init__.py new file mode 100644 index 00000000..34b34a6c --- /dev/null +++ b/tailbone/forms/__init__.py @@ -0,0 +1,30 @@ +# -*- 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 . +# +################################################################################ +""" +Forms Library +""" + +# 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 new file mode 100644 index 00000000..6183d17f --- /dev/null +++ b/tailbone/forms/common.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2023 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Common Forms +""" + +from rattail.db import model + +import colander + + +@colander.deferred +def validate_user(node, kw): + session = kw['session'] + def validate(node, value): + user = session.get(model.User, value) + if not user: + raise colander.Invalid(node, "User not found") + return user.uuid + return validate + + +class Feedback(colander.Schema): + """ + Form schema for user feedback. + """ + email_key = colander.SchemaNode(colander.String(), + missing=colander.null) + + referrer = colander.SchemaNode(colander.String()) + + user = colander.SchemaNode(colander.String(), + missing=colander.null, + validator=validate_user) + + user_name = colander.SchemaNode(colander.String(), + missing=colander.null) + + please_reply_to = colander.SchemaNode(colander.String(), + missing=colander.null) + + message = colander.SchemaNode(colander.String()) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py new file mode 100644 index 00000000..4024557b --- /dev/null +++ b/tailbone/forms/core.py @@ -0,0 +1,1459 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Forms Core +""" + +import hashlib +import json +import logging +import warnings +from collections import OrderedDict + +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY +from wuttjamaican.util import UNSPECIFIED + +from rattail.util import pretty_boolean +from rattail.db.util import get_fieldnames + +import colander +import deform +from colanderalchemy import SQLAlchemySchemaNode +from colanderalchemy.schema import _creation_order +from deform import widget as dfwidget +from pyramid_deform import SessionFileUploadTempStore +from pyramid.renderers import render +from webhelpers2.html import tags, HTML + +from wuttaweb.util import FieldList, get_form_data, make_json_safe + +from tailbone.db import Session +from tailbone.util import raw_datetime, render_markdown +from tailbone.forms import types +from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, + JQueryDateWidget, JQueryTimeWidget, + FileUploadWidget, MultiFileUploadWidget) +from tailbone.exceptions import TailboneJSONFieldError + + +log = logging.getLogger(__name__) + + +def get_association_proxy(mapper, field): + """ + Returns the association proxy corresponding to the given field name if one + exists, or ``None``. + """ + try: + desc = getattr(mapper.all_orm_descriptors, field) + except AttributeError: + pass + else: + if desc.extension_type == ASSOCIATION_PROXY: + return desc + + +def get_association_proxy_target(inspector, field): + """ + Returns the property on the main class, which represents the "target" + for the given association proxy field name. Typically this will refer + to the "extension" model class. + """ + proxy = get_association_proxy(inspector, field) + if proxy: + proxy_target = inspector.get_property(proxy.target_collection) + if isinstance(proxy_target, orm.RelationshipProperty) and not proxy_target.uselist: + return proxy_target + + +def get_association_proxy_column(inspector, field): + """ + Returns the property on the proxy target class, for the column which is + reflected by the proxy. + """ + proxy_target = get_association_proxy_target(inspector, field) + if proxy_target: + if proxy_target.mapper.has_property(field): + prop = proxy_target.mapper.get_property(field) + if isinstance(prop, orm.ColumnProperty) and isinstance(prop.columns[0], sa.Column): + return prop + + +class CustomSchemaNode(SQLAlchemySchemaNode): + + def association_proxy(self, field): + """ + Returns the association proxy corresponding to the given field name if + one exists, or ``None``. + """ + return get_association_proxy(self.inspector, field) + + def association_proxy_target(self, field): + """ + Returns the property on the main class, which represents the "target" + for the given association proxy field name. Typically this will refer + to the "extension" model class. + """ + return get_association_proxy_target(self.inspector, field) + + def association_proxy_column(self, field): + """ + Returns the property on the proxy target class, for the column which is + reflected by the proxy. + """ + return get_association_proxy_column(self.inspector, field) + + def supported_association_proxy(self, field): + """ + Returns boolean indicating whether the association proxy corresponding + to the given field name, is "supported" with typical logic. + """ + if not self.association_proxy_column(field): + return False + return True + + def add_nodes(self, includes, excludes, overrides): + """ + Add all automatic nodes to the schema. + + .. note:: + This method was copied from upstream and modified to add automatic + handling of "association proxy" fields. + """ + if set(excludes) & set(includes): + msg = 'excludes and includes are mutually exclusive.' + raise ValueError(msg) + + # sorted to maintain the order in which the attributes + # are defined + properties = sorted(self.inspector.attrs, key=_creation_order) + if excludes: + if includes: + raise ValueError("Must pass includes *or* excludes, but not both") + supported = [prop.key for prop in properties + if prop.key not in excludes] + elif includes: + supported = includes + elif includes is not None: + supported = [] + + for name in supported: + prop = self.inspector.attrs.get(name, name) + + if name in excludes or (includes and name not in includes): + log.debug('Attribute %s skipped imperatively', name) + continue + + name_overrides_copy = overrides.get(name, {}).copy() + + if (isinstance(prop, orm.ColumnProperty) + and isinstance(prop.columns[0], sa.Column)): + node = self.get_schema_from_column( + prop, + name_overrides_copy + ) + elif isinstance(prop, orm.RelationshipProperty): + if prop.mapper.class_ in self.parents_ and name not in includes: + continue + node = self.get_schema_from_relationship( + prop, + name_overrides_copy + ) + elif isinstance(prop, colander.SchemaNode): + node = prop + else: + + # magic for association proxy fields + column = self.association_proxy_column(name) + if column: + node = self.get_schema_from_column(column, name_overrides_copy) + + else: + log.debug( + 'Attribute %s skipped due to not being ' + 'a ColumnProperty or RelationshipProperty', + name + ) + continue + + if node is not None: + self.add(node) + + def get_schema_from_relationship(self, prop, overrides): + """ Build and return a :class:`colander.SchemaNode` for a relationship. + """ + + # for some reason ColanderAlchemy wants to crawl our entire ORM by + # default, by way of relationships. this 'excludes' hack is used to + # prevent that, by forcing skip of 2nd-level relationships + + excludes = [] + if isinstance(prop, orm.RelationshipProperty): + for next_prop in prop.mapper.iterate_properties: + + # don't include secondary relationships + if isinstance(next_prop, orm.RelationshipProperty): + excludes.append(next_prop.key) + + # don't include fields of binary type + elif isinstance(next_prop, orm.ColumnProperty): + for column in next_prop.columns: + if isinstance(column.type, sa.LargeBinary): + excludes.append(next_prop.key) + + if excludes: + overrides['excludes'] = excludes + + return super().get_schema_from_relationship(prop, overrides) + + def dictify(self, obj): + """ Return a dictified version of `obj` using schema information. + + .. note:: + This method was copied from upstream and modified to add automatic + handling of "association proxy" fields. + """ + dict_ = super().dictify(obj) + for node in self: + + name = node.name + if name not in dict_: + # we're only processing association proxy fields here + if not self.supported_association_proxy(name): + continue + + value = getattr(obj, name) + if value is None: + if isinstance(node.typ, colander.String): + # colander has an issue with `None` on a String type + # where it translates it into "None". Let's check + # for that specific case and turn it into a + # `colander.null`. + dict_[name] = colander.null + else: + # A specific case this helps is with Integer where + # `None` is an invalid value. We call serialize() + # to test if we have a value that will work later + # for serialization and then allow it if it doesn't + # raise an exception. Hopefully this also catches + # issues with user defined types and future issues. + try: + node.serialize(value) + except: + dict_[name] = colander.null + else: + dict_[name] = value + else: + dict_[name] = value + + return dict_ + + def objectify(self, dict_, context=None): + """ Return an object representing ``dict_`` using schema information. + + .. note:: + This method was copied from upstream and modified to add automatic + handling of "association proxy" fields. + """ + mapper = self.inspector + context = mapper.class_() if context is None else context + for attr in dict_: + if mapper.has_property(attr): + prop = mapper.get_property(attr) + if hasattr(prop, 'mapper'): + cls = prop.mapper.class_ + if prop.uselist: + # Sequence of objects + value = [self[attr].children[0].objectify(obj) + for obj in dict_[attr]] + else: + # Single object + value = self[attr].objectify(dict_[attr]) + else: + value = dict_[attr] + if value is colander.null: + # `colander.null` is never an appropriate + # value to be placed on an SQLAlchemy object + # so we translate it into `None`. + value = None + setattr(context, attr, value) + + else: + + # try to process association proxy field + if self.supported_association_proxy(attr): + value = dict_[attr] + if value is colander.null: + # `colander.null` is never an appropriate + # value to be placed on an SQLAlchemy object + # so we translate it into `None`. + value = None + setattr(context, attr, value) + + else: + # Ignore attributes if they are not mapped + log.debug( + 'SQLAlchemySchemaNode.objectify: %s not found on ' + '%s. This property has been ignored.', + attr, self + ) + continue + + return context + + +class Form(object): + """ + Base class for all forms. + """ + save_label = "Submit" + update_label = "Save" + show_cancel = True + auto_disable = True + auto_disable_save = True + auto_disable_cancel = True + + def __init__(self, fields=None, schema=None, request=None, readonly=False, readonly_fields=[], + model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, + assume_local_times=False, renderers=None, renderer_kwargs={}, + hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, + action_url=None, cancel_url=None, + vue_tagname=None, + vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={}, + # TODO: ugh this is getting out hand! + can_edit_help=False, edit_help_url=None, route_prefix=None, + **kwargs + ): + self.fields = None + if fields is not None: + self.set_fields(fields) + self.schema = schema + if self.fields is None and self.schema: + self.set_fields([f.name for f in self.schema]) + self.grouping = None + self.request = request + self.readonly = readonly + self.readonly_fields = set(readonly_fields or []) + self.model_instance = model_instance + self.model_class = model_class + if self.model_instance and not self.model_class and not isinstance(self.model_instance, dict): + self.model_class = type(self.model_instance) + if self.model_class and self.fields is None: + self.set_fields(self.make_fields()) + self.appstruct = appstruct + self.nodes = nodes or {} + self.enums = enums or {} + self.labels = labels or {} + self.assume_local_times = assume_local_times + if renderers is None and self.model_class: + self.renderers = self.make_renderers() + else: + self.renderers = renderers or {} + self.renderer_kwargs = renderer_kwargs or {} + self.hidden = hidden or {} + self.widgets = widgets or {} + self.defaults = defaults or {} + self.validators = validators or {} + self.required = required or {} + self.helptext = helptext or {} + self.dynamic_helptext = {} + self.focus_spec = focus_spec + self.action_url = action_url + self.cancel_url = cancel_url + + # vue_tagname + self.vue_tagname = vue_tagname + if not self.vue_tagname and kwargs.get('component'): + warnings.warn("component kwarg is deprecated for Form(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + self.vue_tagname = kwargs['component'] + if not self.vue_tagname: + self.vue_tagname = 'tailbone-form' + + self.vuejs_component_kwargs = vuejs_component_kwargs or {} + self.vuejs_field_converters = vuejs_field_converters or {} + self.json_data = json_data or {} + self.included_templates = included_templates or {} + self.can_edit_help = can_edit_help + self.edit_help_url = edit_help_url + self.route_prefix = route_prefix + + self.button_icon_submit = kwargs.get('button_icon_submit', 'save') + + def __iter__(self): + return iter(self.fields) + + @property + def vue_component(self): + """ + String name for the Vue component, e.g. ``'TailboneGrid'``. + + This is a generated value based on :attr:`vue_tagname`. + """ + words = self.vue_tagname.split('-') + return ''.join([word.capitalize() for word in words]) + + @property + def component(self): + """ + DEPRECATED - use :attr:`vue_tagname` instead. + """ + warnings.warn("Form.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + + @property + def component_studly(self): + """ + DEPRECATED - use :attr:`vue_component` instead. + """ + warnings.warn("Form.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component + + def get_button_label_submit(self): + """ """ + if hasattr(self, '_button_label_submit'): + return self._button_label_submit + + label = getattr(self, 'submit_label', None) + if label: + return label + + return self.save_label + + def set_button_label_submit(self, value): + """ """ + self._button_label_submit = value + + # wutta compat + button_label_submit = property(get_button_label_submit, + set_button_label_submit) + + def __contains__(self, item): + return item in self.fields + + def set_fields(self, fields): + self.fields = FieldList(fields) + + def make_fields(self): + """ + Return a default list of fields, based on :attr:`model_class`. + """ + if not self.model_class: + raise ValueError("Must define model_class to use make_fields()") + + return get_fieldnames(self.request.rattail_config, self.model_class, + columns=True, proxies=True, relations=True) + + def set_grouping(self, items): + self.grouping = OrderedDict(items) + + def make_renderers(self): + """ + Return a default set of field renderers, based on :attr:`model_class`. + """ + if not self.model_class: + raise ValueError("Must define model_class to use make_renderers()") + + inspector = sa.inspect(self.model_class) + renderers = {} + + # TODO: clearly this should be leaner... + + # first look at regular column fields + for prop in inspector.iterate_properties: + if isinstance(prop, orm.ColumnProperty): + if len(prop.columns) == 1: + column = prop.columns[0] + if isinstance(column.type, sa.DateTime): + if self.assume_local_times: + renderers[prop.key] = self.render_datetime_local + else: + renderers[prop.key] = self.render_datetime + elif isinstance(column.type, sa.Boolean): + renderers[prop.key] = self.render_boolean + + # then look at association proxy fields + for key, desc in inspector.all_orm_descriptors.items(): + if desc.extension_type == ASSOCIATION_PROXY: + prop = get_association_proxy_column(inspector, key) + if prop: + column = prop.columns[0] + if isinstance(column.type, sa.DateTime): + renderers[key] = self.render_datetime + elif isinstance(column.type, sa.Boolean): + renderers[key] = self.render_boolean + + return renderers + + def append(self, field): + self.fields.append(field) + + def insert(self, index, field): + self.fields.insert(index, field) + + def insert_before(self, field, newfield): + self.fields.insert_before(field, newfield) + + def insert_after(self, field, newfield): + self.fields.insert_after(field, newfield) + + def replace(self, field, newfield): + self.insert_after(field, newfield) + self.remove(field) + + def remove(self, *args): + for arg in args: + if arg in self.fields: + self.fields.remove(arg) + + # TODO: deprecare / remove this + def remove_field(self, key): + self.remove(key) + + # TODO: deprecare / remove this + def remove_fields(self, *args): + self.remove(*args) + + def make_schema(self): + if not self.schema: + + if not self.model_class: + # TODO + raise NotImplementedError + + mapper = orm.class_mapper(self.model_class) + + # first filter our "full" field list so we ignore certain ones. in + # particular we don't want readonly fields in the schema, or any + # which appear to be "private" + includes = [f for f in self.fields + if f not in self.readonly_fields + and not f.startswith('_') + and f != 'versions'] + + # derive list of "auto included" fields. this is all "included" + # fields which are part of the SQLAlchemy ORM for the object + auto_includes = [] + property_keys = [p.key for p in mapper.iterate_properties] + inspector = sa.inspect(self.model_class) + for field in includes: + if field in self.nodes: + continue # these are explicitly set; no magic wanted + if field in property_keys: + auto_includes.append(field) + elif get_association_proxy(inspector, field): + auto_includes.append(field) + + # make schema - only include *property* fields at this point + schema = CustomSchemaNode(self.model_class, includes=auto_includes) + + # for now, must manually add any "extra" fields? this includes all + # association proxy fields, not sure how other fields will behave + for field in includes: + if field not in schema: + node = self.nodes.get(field) + if not node: + node = colander.SchemaNode(colander.String(), name=field, missing='') + if not node.name: + node.name = field + schema.add(node) + + # apply any label overrides + for key, label in self.labels.items(): + if key in schema: + schema[key].title = label + + # apply any widget overrides + for key, widget in self.widgets.items(): + if key in schema: + schema[key].widget = widget + + # TODO: we are now doing this when making deform.Form, in which + # case, do we still need to do it here? + # apply any default values + for key, default in self.defaults.items(): + if key in schema: + schema[key].default = default + + # apply any validators + for key, validator in self.validators.items(): + if key is None: + # this one is form-wide + schema.validator = validator + elif key in schema: + schema[key].validator = validator + + # apply required flags + for key, required in self.required.items(): + if key in schema: + if required: + schema[key].missing = colander.required + else: + schema[key].missing = None # TODO? + + self.schema = schema + + return self.schema + + def set_label(self, key, label): + self.labels[key] = label + + # update schema if necessary + if self.schema and key in self.schema: + self.schema[key].title = label + + def get_label(self, key): + config = self.request.rattail_config + app = config.get_app() + return self.labels.get(key, app.make_title(key)) + + def set_readonly(self, key, readonly=True): + if readonly: + self.readonly_fields.add(key) + else: + if key in self.readonly_fields: + self.readonly_fields.remove(key) + + def set_node(self, key, nodeinfo, **kwargs): + if isinstance(nodeinfo, colander.SchemaNode): + node = nodeinfo + else: + kwargs.setdefault('name', key) + node = colander.SchemaNode(nodeinfo, **kwargs) + self.nodes[key] = node + + # must explicitly replace node, if we already have a schema + if self.schema: + self.schema[key] = node + + def set_type(self, key, type_, **kwargs): + + if type_ == 'datetime': + self.set_renderer(key, self.render_datetime) + + elif type_ == 'datetime_falafel': + self.set_renderer(key, self.render_datetime) + self.set_node(key, types.FalafelDateTime(request=self.request)) + if kwargs.get('helptext'): + app = self.request.rattail_config.get_app() + timezone = app.get_timezone() + self.set_helptext(key, f"NOTE: all times are local to {timezone}") + + elif type_ == 'datetime_local': + self.set_renderer(key, self.render_datetime_local) + elif type_ == 'date_plain': + self.set_widget(key, PlainDateWidget()) + elif type_ == 'date_jquery': + # TODO: is this safe / a good idea? + # self.set_node(key, colander.Date()) + self.set_widget(key, JQueryDateWidget()) + + elif type_ == 'time_jquery': + self.set_node(key, types.JQueryTime()) + self.set_widget(key, JQueryTimeWidget()) + + elif type_ == 'time_falafel': + self.set_node(key, types.FalafelTime(request=self.request)) + + elif type_ == 'duration': + self.set_renderer(key, self.render_duration) + elif type_ == 'boolean': + self.set_renderer(key, self.render_boolean) + self.set_widget(key, dfwidget.CheckboxWidget()) + elif type_ == 'currency': + self.set_renderer(key, self.render_currency) + elif type_ == 'quantity': + self.set_renderer(key, self.render_quantity) + elif type_ == 'percent': + self.set_renderer(key, self.render_percent) + elif type_ == 'gpc': + self.set_renderer(key, self.render_gpc) + elif type_ == 'enum': + self.set_renderer(key, self.render_enum) + elif type_ == 'codeblock': + self.set_renderer(key, self.render_codeblock) + self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + elif type_ == 'text': + self.set_renderer(key, self.render_pre_sans_serif) + self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + elif type_ == 'text_wrapped': + self.set_renderer(key, self.render_pre_sans_serif_wrapped) + self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) + elif type_ == 'file': + tmpstore = SessionFileUploadTempStore(self.request) + kw = {'widget': FileUploadWidget(tmpstore, request=self.request), + 'title': self.get_label(key)} + if 'required' in kwargs and not kwargs['required']: + kw['missing'] = colander.null + self.set_node(key, colander.SchemaNode(deform.FileData(), **kw)) + elif type_ == 'multi_file': + tmpstore = SessionFileUploadTempStore(self.request) + file_node = colander.SchemaNode(deform.FileData(), + name='upload') + + kw = {'name': key, + 'title': self.get_label(key), + 'widget': MultiFileUploadWidget(tmpstore)} + # if 'required' in kwargs and not kwargs['required']: + # kw['missing'] = colander.null + if kwargs.get('validate_unique'): + kw['validator'] = self.validate_multiple_files_unique + files_node = colander.SequenceSchema(file_node, **kw) + self.set_node(key, files_node) + else: + raise ValueError("unknown type for '{}' field: {}".format(key, type_)) + + def validate_multiple_files_unique(self, node, value): + + # get SHA256 hash for each file; error if duplicates encountered + hashes = {} + for fileinfo in value: + fp = fileinfo['fp'] + fp.seek(0) + filehash = hashlib.sha256(fp.read()).hexdigest() + if filehash in hashes: + node.raise_invalid(f"Duplicate file detected: {fileinfo['filename']}") + hashes[filehash] = fileinfo + + def set_enum(self, key, enum, empty=None): + if enum: + self.enums[key] = enum + self.set_type(key, 'enum') + values = list(enum.items()) + if empty: + values.insert(0, empty) + self.set_widget(key, dfwidget.SelectWidget(values=values)) + else: + self.enums.pop(key, None) + + def get_enum(self, key): + return self.enums.get(key) + + # TODO: i don't think this is actually being used anywhere..? + def set_enum_value(self, key, enum_key, enum_value): + enum = self.enums.get(key) + if enum: + enum[enum_key] = enum_value + + def set_renderer(self, key, renderer): + if renderer is None: + if key in self.renderers: + del self.renderers[key] + else: + self.renderers[key] = renderer + + def add_renderer_kwargs(self, key, kwargs): + self.renderer_kwargs.setdefault(key, {}).update(kwargs) + + def get_renderer_kwargs(self, key): + return self.renderer_kwargs.get(key, {}) + + def set_renderer_kwargs(self, key, kwargs): + self.renderer_kwargs[key] = kwargs + + def set_input_handler(self, key, value): + """ + Convenience method to assign "input handler" callback code for + the given field. + """ + self.add_renderer_kwargs(key, {'input_handler': value}) + + def set_hidden(self, key, hidden=True): + self.hidden[key] = hidden + + def set_widget(self, key, widget): + self.widgets[key] = widget + + # update schema if necessary + if self.schema and key in self.schema: + self.schema[key].widget = widget + + def set_validator(self, key, validator): + """ + Set the validator for the schema node represented by the given + key. + + :param key: Normally this the name of one of the fields + contained in the form. It can also be ``None`` in which + case the validator pertains to the form at large instead of + one of the fields. + + :param validator: Callable which accepts ``(node, value)`` + args. + """ + self.validators[key] = validator + + # we normally apply the validator when creating the schema, so + # if this form already has a schema, then go ahead and apply + # the validator to it + if self.schema and key in self.schema: + self.schema[key].validator = validator + + def set_required(self, key, required=True): + """ + Set whether or not value is required for a given field. + """ + self.required[key] = required + + def set_default(self, key, value): + """ + Set the default value for a given field. + """ + self.defaults[key] = value + + def set_helptext(self, key, value, dynamic=False): + """ + Set the help text for a given field. + """ + # nb. must avoid newlines, they cause some weird "blank page" error?! + self.helptext[key] = value.replace('\n', ' ') + if value and dynamic: + self.dynamic_helptext[key] = True + else: + self.dynamic_helptext.pop(key, None) + + def has_helptext(self, key): + """ + Returns boolean indicating whether the given field has accompanying + help text. + """ + return key in self.helptext + + def render_helptext(self, key): + """ + Render the help text for the given field. + """ + text = self.helptext[key] + text = text.replace('"', '"') + return HTML.literal(text) + + def set_vuejs_field_converter(self, field, converter): + self.vuejs_field_converters[field] = converter + + def render(self, **kwargs): + 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'): + + schema = self.make_schema() + + # TODO: we are still also doing this when making the schema, but + # seems like this should be the right place instead? + # apply any default values + for key, default in self.defaults.items(): + if key in schema: + schema[key].default = default + + # get initial form values from model instance + kwargs = {} + # TODO: ugh, this is necessary to avoid some logic + # which assumes a ColanderAlchemy schema i think? + if self.appstruct is not UNSPECIFIED: + if self.appstruct: + kwargs['appstruct'] = self.appstruct + elif self.model_instance: + if self.model_class: + kwargs['appstruct'] = schema.dictify(self.model_instance) + else: + kwargs['appstruct'] = self.model_instance + + # create form + form = deform.Form(schema, **kwargs) + form.tailbone_form = self + + # set readonly widget where applicable + for field in self.readonly_fields: + if field in form: + form[field].widget = ReadonlyWidget() + + self.deform_form = form + + return self.deform_form + + def render_vue_template(self, template='/forms/deform.mako', **context): + """ """ + output = self.render_deform(template=template, **context) + return HTML.literal(output) + + def render_deform(self, dform=None, template=None, **kwargs): + if not template: + template = '/forms/deform.mako' + + if dform is None: + dform = self.make_deform_form() + + # TODO: would perhaps be nice to leverage deform's default rendering + # someday..? i.e. using Chameleon *.pt templates + # return dform.render() + + context = kwargs + context['form'] = self + context['dform'] = dform + context.setdefault('can_edit_help', self.can_edit_help) + if context['can_edit_help']: + context.setdefault('edit_help_url', self.edit_help_url) + context['field_labels'] = self.get_field_labels() + context['field_markdowns'] = self.get_field_markdowns() + context.setdefault('form_kwargs', {}) + # TODO: deprecate / remove the latter option here + if self.auto_disable_save or self.auto_disable: + context['form_kwargs'].setdefault('ref', self.vue_component) + context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component) + if self.focus_spec: + context['form_kwargs']['data-focus'] = self.focus_spec + context['request'] = self.request + context['readonly_fields'] = self.readonly_fields + context['render_field_readonly'] = self.render_field_readonly + return render(template, context) + + def get_field_labels(self): + return dict([(field, self.get_label(field)) + for field in self]) + + def get_field_markdowns(self, session=None): + app = self.request.rattail_config.get_app() + model = app.model + session = session or Session() + + if not hasattr(self, 'field_markdowns'): + infos = session.query(model.TailboneFieldInfo)\ + .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\ + .all() + self.field_markdowns = dict([(info.field_name, info.markdown_text) + for info in infos]) + + return self.field_markdowns + + def get_vue_field_value(self, key): + """ """ + if key not in self.fields: + return + + dform = self.get_deform() + if key not in dform: + return + + field = dform[key] + return make_json_safe(field.cstruct) + + def get_vuejs_model_value(self, field): + """ + This method must return "raw" JS which will be assigned as the initial + model value for the given field. This JS will be written as part of + the overall response, to be interpreted on the client side. + """ + if field.name in self.vuejs_field_converters: + convert = self.vuejs_field_converters[field.name] + value = convert(field.cstruct) + return json.dumps(value) + + if isinstance(field.schema.typ, colander.Set): + if field.cstruct is colander.null: + return '[]' + + try: + return self.jsonify_value(field.cstruct) + except Exception as error: + raise TailboneJSONFieldError(field.name, error) + + def jsonify_value(self, value): + """ + Take a Python value and convert to JSON + """ + if value is colander.null: + return 'null' + + if isinstance(value, dfwidget.filedict): + # TODO: we used to always/only return 'null' here but hopefully + # this also works, to show existing filename when present + if value and value['filename']: + return json.dumps({'name': value['filename']}) + return 'null' + + elif isinstance(value, list) and all([isinstance(f, dfwidget.filedict) + for f in value]): + return json.dumps([{'name': f['filename']} + for f in value]) + + app = self.request.rattail_config.get_app() + value = app.json_friendly(value) + return json.dumps(value) + + def get_error_messages(self, field): + if field.error: + return field.error.messages() + + error = self.make_deform_form().error + if error: + if isinstance(error, colander.Invalid): + if error.node.name == field.name: + return error.messages() + + def messages_json(self, messages): + dump = json.dumps(messages) + dump = dump.replace("'", ''') + return dump + + def field_visible(self, field): + if self.hidden and self.hidden.get(field): + return False + return True + + def set_vuejs_component_kwargs(self, **kwargs): + self.vuejs_component_kwargs.update(kwargs) + + def render_vue_tag(self, **kwargs): + """ """ + return self.render_vuejs_component(**kwargs) + + def render_vuejs_component(self, **kwargs): + """ + Render the Vue.js component HTML for the form. + + Most typically this is something like: + + .. code-block:: html + + + + """ + 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] 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', + } + + # add some magic for file input fields + if field and isinstance(field.schema.typ, deform.FileData): + attrs['class_'] = 'file' + + # next we will build array of messages to display..some + # fields always show a "helptext" msg, and some may have + # validation errors.. + field_type = None + messages = [] + + # show errors if present + error_messages = self.get_error_messages(field) if field else None + if error_messages: + field_type = 'is-danger' + messages.extend(error_messages) + + # show helptext if present + # TODO: older logic did this only if field was *not* + # readonly, perhaps should add that back.. + if self.has_helptext(fieldname): + messages.append(self.render_helptext(fieldname)) + + # ..okay now we can declare the field messages and type + if field_type: + attrs['type'] = field_type + if messages: + if len(messages) == 1: + msg = messages[0] + if msg.startswith('`') and msg.endswith('`'): + attrs[':message'] = msg + else: + attrs['message'] = msg + else: + # nb. must pass an array as JSON string + attrs[':message'] = '[{}]'.format(', '.join([ + "'{}'".format(msg.replace("'", r"\'")) + for msg in messages])) + + # merge anything caller provided + attrs.update(bfield_attrs) + + # render the field widget or whatever + if self.readonly or fieldname in self.readonly_fields: + html = self.render_field_value(fieldname) or HTML.tag('span') + if type(html) is str: + html = HTML.tag('span', c=[html]) + elif field: + html = field.serialize(**self.get_renderer_kwargs(fieldname)) + html = HTML.literal(html) + + # may need a complex label + label_contents = [label] + + # add 'help' icon/tooltip if defined + if markdowns.get(fieldname): + icon = HTML.tag('b-icon', size='is-small', pack='fas', + icon='question-circle') + tooltip = render_markdown(markdowns[fieldname]) + + # nb. must apply hack to get