Compare commits

..

1 commit

Author SHA1 Message Date
Lance Edgar 1150e6f7a6 savepoint 2017-02-16 21:33:54 -06:00
611 changed files with 26320 additions and 89674 deletions

3
.gitignore vendored
View file

@ -1,8 +1,5 @@
*~
*.pyc
.coverage
.tox/
dist/
docs/_build/
htmlcov/
Tailbone.egg-info/

View file

@ -1,654 +0,0 @@
# 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.3 (2024-11-19)
### Fix
- avoid error for trainwreck query when not a customer
## v0.22.2 (2024-11-18)
### Fix
- use local/custom enum for continuum operations
- add basic master view for Product Costs
- show continuum operation type when viewing version history
- always define `app` attr for ViewSupplement
- avoid deprecated import
## v0.22.1 (2024-11-02)
### Fix
- fix submit button for running problem report
- avoid deprecated grid method
## v0.22.0 (2024-10-22)
### Feat
- add support for new ordering batch from parsed file
### Fix
- avoid deprecated method to suggest username
## v0.21.11 (2024-10-03)
### Fix
- custom method for adding grid action
- become/stop root should redirect to previous url
## v0.21.10 (2024-09-15)
### Fix
- update project repo links, kallithea -> forgejo
- use better icon for submit button on login page
- wrap notes text for batch view
- expose datasync consumer batch size via configure page
## v0.21.9 (2024-08-28)
### Fix
- render custom attrs in form component tag
## v0.21.8 (2024-08-28)
### Fix
- ignore session kwarg for `MasterView.make_row_grid()`
## v0.21.7 (2024-08-28)
### Fix
- avoid error when form value cannot be obtained
## v0.21.6 (2024-08-28)
### Fix
- avoid error when grid value cannot be obtained
## v0.21.5 (2024-08-28)
### Fix
- set empty string for "-new-" file configure option
## v0.21.4 (2024-08-26)
### Fix
- handle differing email profile keys for appinfo/configure
## v0.21.3 (2024-08-26)
### Fix
- show non-standard config values for app info configure email
## v0.21.2 (2024-08-26)
### Fix
- refactor waterpark base template to use wutta feedback component
- fix input/output file upload feature for configure pages, per oruga
- tweak how grid data translates to Vue template context
- merge filters into main grid template
- add basic wutta view for users
- some fixes for wutta people view
- various fixes for waterpark theme
- avoid deprecated `component` form kwarg
## v0.21.1 (2024-08-22)
### Fix
- misc. bugfixes per recent changes
## v0.21.0 (2024-08-22)
### Feat
- move "most" filtering logic for grid class to wuttaweb
- inherit from wuttaweb templates for home, login pages
- inherit from wuttaweb for AppInfoView, appinfo/configure template
- add "has output file templates" config option for master view
### Fix
- change grid reset-view param name to match wuttaweb
- move "searchable columns" grid feature to wuttaweb
- use wuttaweb to get/render csrf token
- inherit from wuttaweb for appinfo/index template
- prefer wuttaweb config for "home redirect to login" feature
- fix master/index template rendering for waterpark theme
- fix spacing for navbar logo/title in waterpark theme
## v0.20.1 (2024-08-20)
### Fix
- fix default filter verbs logic for workorder status
## v0.20.0 (2024-08-20)
### Feat
- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy
- refactor templates to simplify base/page/form structure
### Fix
- avoid deprecated reference to app db engine
## v0.19.3 (2024-08-19)
### Fix
- add pager stats to all grid vue data (fixes view history)
## v0.19.2 (2024-08-19)
### Fix
- sort on frontend for appinfo package listing grid
- prefer attr over key lookup when getting model values
- replace all occurrences of `component_studly` => `vue_component`
## v0.19.1 (2024-08-19)
### Fix
- fix broken user auth for web API app
## v0.19.0 (2024-08-18)
### Feat
- move multi-column grid sorting logic to wuttaweb
- move single-column grid sorting logic to wuttaweb
### Fix
- fix misc. errors in grid template per wuttaweb
- fix broken permission directives in web api startup
## v0.18.0 (2024-08-16)
### Feat
- move "basic" grid pagination logic to wuttaweb
- inherit from wutta base class for Grid
- inherit most logic from wuttaweb, for GridAction
### Fix
- avoid route error in user view, when using wutta people view
- fix some more wutta compat for base template
## v0.17.0 (2024-08-15)
### Feat
- use wuttaweb for `get_liburl()` logic
## v0.16.1 (2024-08-15)
### Fix
- improve wutta People view a bit
- update references to `get_class_hierarchy()`
- tweak template for `people/view_profile` per wutta compat
## v0.16.0 (2024-08-15)
### Feat
- add first wutta-based master, for PersonView
- refactor forms/grids/views/templates per wuttaweb compat
## v0.15.6 (2024-08-13)
### Fix
- avoid `before_render` subscriber hook for web API
- simplify verbiage for batch execution panel
## v0.15.5 (2024-08-09)
### Fix
- assign convenience attrs for all views (config, app, enum, model)
## v0.15.4 (2024-08-09)
### Fix
- avoid bug when checking current theme
## v0.15.3 (2024-08-08)
### Fix
- fix timepicker `parseTime()` when value is null
## v0.15.2 (2024-08-06)
### Fix
- use auth handler, avoid legacy calls for role/perm checks
## v0.15.1 (2024-08-05)
### Fix
- move magic `b` template context var to wuttaweb
## v0.15.0 (2024-08-05)
### Feat
- move more subscriber logic to wuttaweb
### Fix
- use wuttaweb logic for `util.get_form_data()`
## v0.14.5 (2024-08-03)
### Fix
- use auth handler instead of deprecated auth functions
- avoid duplicate `partial` param when grid reloads data
## v0.14.4 (2024-07-18)
### Fix
- fix more settings persistence bug(s) for datasync/configure
- fix modals for luigi tasks page, per oruga
## v0.14.3 (2024-07-17)
### Fix
- fix auto-collapse title for viewing trainwreck txn
- allow auto-collapse of header when viewing trainwreck txn
## v0.14.2 (2024-07-15)
### Fix
- add null menu handler, for use with API apps
## v0.14.1 (2024-07-14)
### Fix
- update usage of auth handler, per rattail changes
- fix model reference in menu handler
- fix bug when making "integration" menus
## v0.14.0 (2024-07-14)
### Feat
- move core menu logic to wuttaweb
## v0.13.2 (2024-07-13)
### Fix
- fix logic bug for datasync/config settings save
## v0.13.1 (2024-07-13)
### Fix
- fix settings persistence bug(s) for datasync/configure page
## v0.13.0 (2024-07-12)
### Feat
- begin integrating WuttaWeb as upstream dependency
### Fix
- cast enum as list to satisfy deform widget
## v0.12.1 (2024-07-11)
### Fix
- refactor `config.get_model()` => `app.model`
## v0.12.0 (2024-07-09)
### Feat
- drop python 3.6 support, use pyproject.toml (again)
## v0.11.10 (2024-07-05)
### Fix
- make the Members tab optional, for profile view
## v0.11.9 (2024-07-05)
### Fix
- do not show flash message when changing app theme
- improve collapse panels for butterball theme
- expand input for butterball theme
- add xref button to customer profile, for trainwreck txn view
- add optional Transactions tab for profile view
## v0.11.8 (2024-07-04)
### Fix
- fix grid action icons for datasync/configure, per oruga
- allow view supplements to add extra links for profile employee tab
- leverage import handler method to determine command/subcommand
- add tool to make user account from profile view
## v0.11.7 (2024-07-04)
### Fix
- add stacklevel to deprecation warnings
- require zope.sqlalchemy >= 1.5
- include edit profile email/phone dialogs only if user has perms
- allow view supplements to add to profile member context
- cast enum as list to satisfy deform widget
- expand POD image URL setting input
## v0.11.6 (2024-07-01)
### Fix
- set explicit referrer when changing dbkey
- remove references, dependency for `six` package
## v0.11.5 (2024-06-30)
### Fix
- allow comma in numeric filter input
- add custom url prefix if needed, for fanstatic
- use vue 3.4.31 and oruga 0.8.12 by default
## v0.11.4 (2024-06-30)
### Fix
- start/stop being root should submit POST instead of GET
- require vendor when making new ordering batch via api
- don't escape each address for email attempts grid
## v0.11.3 (2024-06-28)
### Fix
- add link to "resolved by" user for pending products
- handle error when merging 2 records fails
## v0.11.2 (2024-06-18)
### Fix
- hide certain custorder settings if not applicable
- use different logic for buefy/oruga for product lookup keydown
- product records should be touchable
- show flash error message if resolve pending product fails
## v0.11.1 (2024-06-14)
### Fix
- revert back to setup.py + setup.cfg
## v0.11.0 (2024-06-10)
### Feat
- switch from setup.cfg to pyproject.toml + hatchling
## v0.10.16 (2024-06-10)
### Feat
- standardize how app, package versions are determined
### Fix
- avoid deprecated config methods for app/node title
## v0.10.15 (2024-06-07)
### Fix
- do *not* Use `pkg_resources` to determine package versions
## v0.10.14 (2024-06-06)
### Fix
- use `pkg_resources` to determine package versions
## v0.10.13 (2024-06-06)
### Feat
- remove old/unused scaffold for use with `pcreate`
- add 'fanstatic' support for sake of libcache assets
## v0.10.12 (2024-06-04)
### Feat
- require pyramid 2.x; remove 1.x-style auth policies
- remove version cap for deform
- set explicit referrer when changing app theme
- add `<b-tooltip>` component shim
- include extra styles from `base_meta` template for butterball
- include butterball theme by default for new apps
### Fix
- fix product lookup component, per butterball
## v0.10.11 (2024-06-03)
### Feat
- fix vue3 refresh bugs for various views
- fix grid bug for tempmon appliance view, per oruga
- fix ordering worksheet generator, per butterball
- fix inventory worksheet generator, per butterball
## v0.10.10 (2024-06-03)
### Feat
- more butterball fixes for "view profile" template
### Fix
- fix focus for `<b-select>` shim component
## v0.10.9 (2024-06-03)
### Feat
- let master view control context menu items for page
- fix the "new custorder" page for butterball
### Fix
- fix panel style for PO vs. Invoice breakdown in receiving batch
## v0.10.8 (2024-06-02)
### Feat
- add styling for checked grid rows, per oruga/butterball
- fix product view template for oruga/butterball
- allow per-user custom styles for butterball
- use oruga 0.8.9 by default
## v0.10.7 (2024-06-01)
### Feat
- add setting to allow decimal quantities for receiving
- log error if registry has no rattail config
- add column filters for import/export main grid
- escape all unsafe html for grid data
- add speedbumps for delete, set preferred email/phone in profile view
- fix file upload widget for oruga
### Fix
- fix overflow when instance header title is too long (butterball)
## v0.10.6 (2024-05-29)
### Feat
- add way to flag organic products within lookup dialog
- expose db picker for butterball theme
- expose quickie lookup for butterball theme
- fix basic problems with people profile view, per butterball
## v0.10.5 (2024-05-29)
### Feat
- add `<tailbone-timepicker>` component for oruga
## v0.10.4 (2024-05-12)
### Fix
- fix styles for grid actions, per butterball
## v0.10.3 (2024-05-10)
### Fix
- fix bug with grid date filters
## v0.10.2 (2024-05-08)
### Feat
- remove version restriction for pyramid_beaker dependency
- rename some attrs etc. for buefy components used with oruga
- fix "tools" helper for receiving batch view, per oruga
- more data type fixes for ``<tailbone-datepicker>``
- fix "view receiving row" page, per oruga
- tweak styles for grid action links, per butterball
### Fix
- fix employees grid when viewing department (per oruga)
- fix login "enter" key behavior, per oruga
- fix button text for autocomplete
## v0.10.1 (2024-04-28)
### Feat
- sort list of available themes
- update various icon names for oruga compatibility
- show "View This" button when cloning a record
- stop including 'falafel' as available theme
### Fix
- fix vertical alignment in main menu bar, for butterball
- fix upgrade execution logic/UI per oruga
## v0.10.0 (2024-04-28)
This version bump is to reflect adding support for Vue 3 + Oruga via
the 'butterball' theme. There is likely more work to be done for that
yet, but it mostly works at this point.
### Feat
- misc. template and view logic tweaks (applicable to all themes) for
better patterns, consistency etc.
- add initial support for Vue 3 + Oruga, via "butterball" theme
## Older Releases
Please see `docs/OLDCHANGES.rst` for older release notes.

1813
CHANGES.rst Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The 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
our General Public Licenses are 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.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@ -26,44 +24,34 @@ 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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
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.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
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.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
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.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@ -549,35 +537,45 @@ 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.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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
under version 3 of the GNU 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.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
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
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
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
Program specifies that a certain numbered version of the GNU Affero 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
GNU Affero 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
versions of the GNU Affero 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.
@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
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.
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.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
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
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.

View file

@ -3,7 +3,6 @@ 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
@ -11,8 +10,5 @@ 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

View file

@ -1,8 +1,10 @@
# Tailbone
Tailbone
========
Tailbone is an extensible web application based on Rattail. It provides a
"back-office network environment" (BONE) for use in managing retail data.
Please see Rattail's [home page](http://rattailproject.org/) for more
information.
Please see Rattail's `home page`_ for more information.
.. _home page: http://rattailproject.org/

File diff suppressed because it is too large Load diff

View file

@ -1,15 +0,0 @@
``tailbone.api.batch.core``
===========================
.. automodule:: tailbone.api.batch.core
.. autoclass:: APIBatchMixin
.. autoclass:: APIBatchView
.. autoclass:: APIBatchRowView
.. autoattribute:: editable
.. autoattribute:: supports_quick_entry

View file

@ -1,41 +0,0 @@
``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

View file

@ -1,6 +0,0 @@
``tailbone.db``
===============
.. automodule:: tailbone.db
:members:

View file

@ -1,6 +0,0 @@
``tailbone.diffs``
==================
.. automodule:: tailbone.diffs
:members:

View file

@ -1,9 +0,0 @@
``tailbone.forms``
==================
.. automodule:: tailbone.forms
:members:
.. autoclass:: tailbone.forms.Form
:members:

View file

@ -1,6 +0,0 @@
``tailbone.forms.widgets``
==========================
.. automodule:: tailbone.forms.widgets
:members:

View file

@ -1,6 +0,0 @@
``tailbone.grids.core``
=======================
.. automodule:: tailbone.grids.core
:members:

View file

@ -1,6 +0,0 @@
``tailbone.grids``
==================
.. automodule:: tailbone.grids
:members:

10
docs/api/newgrids.rst Normal file
View file

@ -0,0 +1,10 @@
.. -*- coding: utf-8 -*-
``tailbone.newgrids``
=====================
.. automodule:: tailbone.newgrids
:members:
.. automodule:: tailbone.newgrids.alchemy
:members:

View file

@ -1,6 +0,0 @@
``tailbone.progress``
=====================
.. automodule:: tailbone.progress
:members:

View file

@ -3,4 +3,5 @@
========================
.. automodule:: tailbone.subscribers
:members:
.. autofunction:: add_rattail_config_attribute_to_request

View file

@ -1,6 +0,0 @@
``tailbone.util``
=================
.. automodule:: tailbone.util
:members:

View file

@ -1,5 +1,29 @@
.. -*- coding: utf-8 -*-
``tailbone.views.batch``
========================
.. automodule:: tailbone.views.batch
.. autoclass:: BatchGrid
:members:
.. autoclass:: FileBatchGrid
:members:
.. autoclass:: BatchCrud
:members:
.. autoclass:: FileBatchCrud
:members:
.. autoclass:: BatchRowGrid
:members:
.. autoclass:: ProductBatchRowGrid
:members:
.. autoclass:: BatchRowCrud
:members:
.. autofunction:: defaults

View file

@ -1,10 +0,0 @@
``tailbone.views.batch.vendorcatalog``
======================================
.. automodule:: tailbone.views.batch.vendorcatalog
.. autoclass:: VendorCatalogsView
:members:
.. autofunction:: includeme

View file

@ -1,6 +0,0 @@
``tailbone.views.core``
=======================
.. automodule:: tailbone.views.core
:members:

View file

@ -1,3 +1,4 @@
.. -*- coding: utf-8 -*-
``tailbone.views.master``
=========================
@ -66,61 +67,12 @@ override when defining your subclass.
.. attribute:: MasterView.grid_factory
Factory callable to be used when creating new grid instances; defaults to
:class:`tailbone.grids.Grid`.
:class:`tailbone.newgrids.alchemy.AlchemyGrid`.
.. 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
.. Methods to Override
.. -------------------
..
.. The following is a list of methods which you can override when defining your
.. subclass.
..
.. .. automethod:: MasterView.get_settings

View file

@ -1,6 +0,0 @@
``tailbone.views.members``
==========================
.. automodule:: tailbone.views.members
:members:

View file

@ -1,9 +0,0 @@
``tailbone.views.purchasing.batch``
===================================
.. automodule:: tailbone.views.purchasing.batch
.. autoclass:: PurchasingBatchView
.. automethod:: save_edit_row_form

View file

@ -1,15 +0,0 @@
``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

View file

@ -0,0 +1,10 @@
``tailbone.views.vendors.catalogs``
===================================
.. automodule:: tailbone.views.vendors.catalogs
.. autoclass:: VendorCatalogsView
:members:
.. autofunction:: includeme

View file

@ -1,8 +0,0 @@
Changelog Archive
=================
.. toctree::
:maxdepth: 1
OLDCHANGES

View file

@ -1,65 +0,0 @@
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.

View file

@ -1,115 +0,0 @@
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.

View file

@ -1,7 +0,0 @@
Console Commands
================
.. contents:: :local:
TODO

View file

@ -1,45 +0,0 @@
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`.

View file

@ -1,21 +1,36 @@
# Configuration file for the Sphinx documentation builder.
# -*- coding: utf-8 -*-
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# Tailbone documentation build configuration file, created by
# sphinx-quickstart on Sat Feb 15 23:15:27 2014.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import sys
import os
from importlib.metadata import version as get_version
execfile(os.path.join(os.pardir, 'tailbone', '_version.py'))
project = 'Tailbone'
copyright = '2010 - 2024, Lance Edgar'
author = 'Lance Edgar'
release = get_version('Tailbone')
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.todo',
@ -23,30 +38,234 @@ extensions = [
'sphinx.ext.viewcode',
]
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {
'rattail': ('https://rattailproject.org/docs/rattail/', None),
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None),
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
# TODO: Add this back, when the FA site is back online...
#'formalchemy': ('http://docs.formalchemy.org/formalchemy/', None),
}
# allow todo entries to show up
todo_include_todos = True
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'Tailbone'
copyright = u'2015, Lance Edgar'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.3'
# The full version, including alpha/beta/rc tags.
release = __version__
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
# -- Options for HTML output ----------------------------------------------
html_theme = 'furo'
html_static_path = ['_static']
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'classic'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
#html_logo = 'images/rattail_avatar.png'
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
#html_extra_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
#htmlhelp_basename = 'Tailbonedoc'
htmlhelp_basename = 'Tailbonedoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('index', 'Tailbone.tex', u'Tailbone Documentation',
u'Lance Edgar', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'tailbone', u'Tailbone Documentation',
[u'Lance Edgar'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'Tailbone', u'Tailbone Documentation',
u'Lance Edgar', 'Tailbone', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu.
#texinfo_no_detailmenu = False

View file

@ -1,78 +0,0 @@
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -2,35 +2,15 @@
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.
Welcome to Tailbone, part of the Rattail project.
Some additional information is available on the `website`_. Certainly not
everything is documented yet, but here you can see what has received some
The documentation you are currently reading is for the Tailbone web application
package. Some additional information is available on the `website`_. Clearly
not everything is documented yet. Below you can see what has received some
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::
@ -42,32 +22,11 @@ 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/newgrids
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
api/views/vendors.catalogs
Documentation To-Do

View file

@ -1,154 +0,0 @@
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',)

View file

@ -1,63 +0,0 @@
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

View file

@ -1,130 +0,0 @@
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

39
fabfile.py vendored Normal file
View file

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Fabric script for Tailbone
"""
from __future__ import unicode_literals, absolute_import
import shutil
from fabric.api import task, local
@task
def release():
"""
Release a new version of 'Tailbone'.
"""
shutil.rmtree('Tailbone.egg-info')
local('python setup.py sdist --formats=gztar upload')

View file

@ -1,103 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "Tailbone"
version = "0.22.3"
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.18.5",
"sa-filters",
"simplejson",
"transaction",
"waitress",
"WebHelpers2",
"WuttaWeb>=0.14.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"

6
setup.cfg Normal file
View file

@ -0,0 +1,6 @@
[nosetests]
nocapture = 1
cover-package = tailbone
cover-erase = 1
cover-html = 1
cover-html-dir = htmlcov

173
setup.py Normal file
View file

@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Setup script for Tailbone
"""
from __future__ import unicode_literals, absolute_import
import os.path
from setuptools import setup, find_packages
here = os.path.abspath(os.path.dirname(__file__))
execfile(os.path.join(here, 'tailbone', '_version.py'))
README = open(os.path.join(here, 'README.rst')).read()
requires = [
#
# Version numbers within comments below have specific meanings.
# Basically the 'low' value is a "soft low," and 'high' a "soft high."
# In other words:
#
# If either a 'low' or 'high' value exists, the primary point to be
# made about the value is that it represents the most current (stable)
# version available for the package (assuming typical public access
# methods) whenever this project was started and/or documented.
# Therefore:
#
# If a 'low' version is present, you should know that attempts to use
# versions of the package significantly older than the 'low' version
# may not yield happy results. (A "hard" high limit may or may not be
# indicated by a true version requirement.)
#
# Similarly, if a 'high' version is present, and especially if this
# project has laid dormant for a while, you may need to refactor a bit
# when attempting to support a more recent version of the package. (A
# "hard" low limit should be indicated by a true version requirement
# when a 'high' version is present.)
#
# In any case, developers and other users are encouraged to play
# outside the lines with regard to these soft limits. If bugs are
# encountered then they should be filed as such.
#
# package # low high
# For now, let's restrict FormEncode to 1.2 since the 1.3 release
# introduces some deprecation warnings. Once we're running 1.2 everywhere
# in production, we can start looking at adding 1.3 support.
# TODO: Remove this restriction.
'FormEncode<=1.2.99', # 1.2.4 1.2.6
# FormAlchemy 1.5 supports Python 3 but is being a little aggressive about
# it, for our needs...We'll have to stick with 1.4 for now.
u'FormAlchemy<=1.4.99', # 1.4.3
# Pyramid 1.3 introduced 'pcreate' command (and friends) to replace
# deprecated 'paster create' (and friends).
'pyramid>=1.3a1', # 1.3b2 1.4.5
'humanize', # 0.5.1
'Mako', # 0.6.2
'pyramid_beaker>=0.6', # 0.6.1
'pyramid_debugtoolbar', # 1.0
'pyramid_exclog', # 0.6
'pyramid_mako', # 1.0.2
'pyramid_simpleform', # 0.6.1
'pyramid_tm', # 0.3
'rattail[db,auth,bouncer]', # 0.5.0
'six', # 1.10.0
'transaction', # 1.2.0
'waitress', # 0.8.1
'WebHelpers', # 1.3
'WTForms', # 2.1
'zope.sqlalchemy', # 0.7
# TODO: Need to figure out what to do about this...
# # This is used to obtain POD image dimensions.
# 'PIL', # 1.1.7
]
extras = {
'docs': [
#
# package # low high
'Sphinx', # 1.2
],
'tests': [
#
# package # low high
'coverage', # 3.6
'fixture', # 1.5
'mock', # 1.0.1
'nose', # 1.3.0
],
}
setup(
name = "Tailbone",
version = __version__,
author = "Lance Edgar",
author_email = "lance@edbob.org",
url = "http://rattailproject.org/",
license = "GNU Affero GPL v3",
description = "Backoffice Web Application for Rattail",
long_description = README,
classifiers = [
'Development Status :: 3 - Alpha',
'Environment :: Web Environment',
'Framework :: Pyramid',
'Intended Audience :: Developers',
'License :: OSI Approved :: GNU Affero General Public License v3',
'Natural Language :: English',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Office/Business',
'Topic :: Software Development :: Libraries :: Python Modules',
],
install_requires = requires,
extras_require = extras,
tests_require = ['Tailbone[tests]'],
test_suite = 'nose.collector',
packages = find_packages(exclude=['tests.*', 'tests']),
include_package_data = True,
zip_safe = False,
entry_points = {
'paste.app_factory': [
'main = tailbone.app:main',
],
'rattail.config.extensions': [
'tailbone = tailbone.config:ConfigExtension',
],
'pyramid.scaffold': [
'rattail = tailbone.scaffolds:RattailTemplate',
],
},
)

View file

@ -1,23 +1,24 @@
# -*- coding: utf-8; -*-
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# 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 General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
# terms of the GNU 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 General Public License for more
# details.
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################

View file

@ -1,9 +1,3 @@
# -*- coding: utf-8; -*-
# -*- coding: utf-8 -*-
try:
from importlib.metadata import version
except ImportError:
from importlib_metadata import version
__version__ = version('Tailbone')
__version__ = u'0.5.83'

View file

@ -1,40 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API
"""
from __future__ import unicode_literals, absolute_import
from .core import APIView, api
from .master import APIMasterView, SortColumn
# TODO: remove this
from .master2 import APIMasterView2
def includeme(config):
config.include('tailbone.api.common')
config.include('tailbone.api.auth')
config.include('tailbone.api.customers')
config.include('tailbone.api.upgrades')
config.include('tailbone.api.users')

View file

@ -1,229 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Auth Views
"""
from cornice import Service
from tailbone.api import APIView, api
from tailbone.db import Session
from tailbone.auth import login_user, logout_user
class AuthenticationView(APIView):
@api
def check_session(self):
"""
View to serve as "no-op" / ping action to check current user's session.
This will establish a server-side web session for the user if none
exists. Note that this also resets the user's session timer.
"""
data = {'ok': True, 'permissions': []}
if self.request.user:
data['user'] = self.get_user_info(self.request.user)
data['permissions'] = list(self.request.user_permissions)
# background color may be set per-request, by some apps
if hasattr(self.request, 'background_color') and self.request.background_color:
data['background_color'] = self.request.background_color
else: # otherwise we use the one from config
data['background_color'] = self.rattail_config.get(
'tailbone', 'background_color')
# TODO: this seems the best place to return some global app
# settings, but maybe not desirable in all cases..in which
# case should caller need to ask for these explicitly? or
# make a different call altogether to get them..?
app = self.get_rattail_app()
customer_handler = app.get_clientele_handler()
data['settings'] = {
'customer_field_dropdown': customer_handler.choice_uses_dropdown(),
}
return data
@api
def login(self):
"""
API login view.
"""
if self.request.method == 'OPTIONS':
return self.request.response
username = self.request.json.get('username')
password = self.request.json.get('password')
if not (username and password):
return {'error': "Invalid username or password"}
# make sure credentials are valid
user = self.authenticate_user(username, password)
if not user:
return {'error': "Invalid username or password"}
# is there some reason this user should not login?
error = self.why_cant_user_login(user)
if error:
return {'error': error}
app = self.get_rattail_app()
auth = app.get_auth_handler()
login_user(self.request, user)
return {
'ok': True,
'user': self.get_user_info(user),
'permissions': list(auth.get_permissions(Session(), user)),
}
def authenticate_user(self, username, password):
app = self.get_rattail_app()
auth = app.get_auth_handler()
return auth.authenticate_user(Session(), username, password)
def why_cant_user_login(self, user):
"""
This method is given a ``User`` instance, which represents someone who
is just now trying to login, and has already cleared the basic hurdle
of providing the correct credentials for a user on file. This method
is responsible then, for further verification that this user *should*
in fact be allowed to login to this app node. If the method determines
a reason the user should *not* be allowed to login, then it should
return that reason as a simple string.
"""
@api
def logout(self):
"""
API logout view.
"""
if self.request.method == 'OPTIONS':
return self.request.response
logout_user(self.request)
return {'ok': True}
@api
def become_root(self):
"""
Elevate the current request to 'root' for full system access.
"""
if not self.request.is_admin:
raise self.forbidden()
self.request.user.record_event(self.enum.USER_EVENT_BECOME_ROOT)
self.request.session['is_root'] = True
return {
'ok': True,
'user': self.get_user_info(self.request.user),
}
@api
def stop_root(self):
"""
Lower the current request from 'root' back to normal access.
"""
if not self.request.is_admin:
raise self.forbidden()
self.request.user.record_event(self.enum.USER_EVENT_STOP_ROOT)
self.request.session['is_root'] = False
return {
'ok': True,
'user': self.get_user_info(self.request.user),
}
@api
def change_password(self):
"""
View which allows a user to change their password.
"""
if self.request.method == 'OPTIONS':
return self.request.response
if not self.request.user:
raise self.forbidden()
if self.request.user.prevent_password_change and not self.request.is_root:
raise self.forbidden()
data = self.request.json_body
# first make sure "current" password is accurate
if not self.authenticate_user(self.request.user, data['current_password']):
return {'error': "The current/old password you provided is incorrect"}
# okay then, set new password
auth = self.app.get_auth_handler()
auth.set_user_password(self.request.user, data['new_password'])
return {
'ok': True,
'user': self.get_user_info(self.request.user),
}
@classmethod
def defaults(cls, config):
cls._auth_defaults(config)
@classmethod
def _auth_defaults(cls, config):
# session
check_session = Service(name='check_session', path='/session')
check_session.add_view('GET', 'check_session', klass=cls)
config.add_cornice_service(check_session)
# login
login = Service(name='login', path='/login')
login.add_view('POST', 'login', klass=cls)
config.add_cornice_service(login)
# logout
logout = Service(name='logout', path='/logout')
logout.add_view('POST', 'logout', klass=cls)
config.add_cornice_service(logout)
# become root
become_root = Service(name='become_root', path='/become-root')
become_root.add_view('POST', 'become_root', klass=cls)
config.add_cornice_service(become_root)
# stop root
stop_root = Service(name='stop_root', path='/stop-root')
stop_root.add_view('POST', 'stop_root', klass=cls)
config.add_cornice_service(stop_root)
# change password
change_password = Service(name='change_password', path='/change-password')
change_password.add_view('POST', 'change_password', klass=cls)
config.add_cornice_service(change_password)
def defaults(config, **kwargs):
base = globals()
AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView'])
AuthenticationView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,29 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2019 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Batches
"""
from __future__ import unicode_literals, absolute_import
from .core import APIBatchView, APIBatchRowView, BatchAPIMasterView

View file

@ -1,360 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Batch Views
"""
import logging
import warnings
from cornice import Service
from tailbone.api import APIMasterView
log = logging.getLogger(__name__)
class APIBatchMixin(object):
"""
Base class for all API views which are meant to handle "batch" *and/or*
"batch row" data.
"""
def get_batch_class(self):
model_class = self.get_model_class()
if hasattr(model_class, '__batch_class__'):
return model_class.__batch_class__
return model_class
def get_handler(self):
"""
Returns a `BatchHandler` instance for the view. All (?) custom batch
API views should define a default handler class; however this may in all
(?) cases be overridden by config also. The specific setting required
to do so will depend on the 'key' for the type of batch involved, e.g.
assuming the 'vendor_catalog' batch:
.. code-block:: ini
[rattail.batch]
vendor_catalog.handler = myapp.batch.vendorcatalog:CustomCatalogHandler
Note that the 'key' for a batch is generally the same as its primary
table name, although technically it is whatever value returns from the
``batch_key`` attribute of the main batch model class.
"""
app = self.get_rattail_app()
key = self.get_batch_class().batch_key
return app.get_batch_handler(key, default=self.default_handler_spec)
class APIBatchView(APIBatchMixin, APIMasterView):
"""
Base class for all API views which are meant to handle "batch" *and/or*
"batch row" data.
"""
supports_toggle_complete = False
supports_execute = False
def __init__(self, request, **kwargs):
super(APIBatchView, self).__init__(request, **kwargs)
self.batch_handler = self.get_handler()
@property
def handler(self):
warnings.warn("the `handler` property is deprecated; "
"please use `batch_handler` instead",
DeprecationWarning, stacklevel=2)
return self.batch_handler
def normalize(self, batch):
app = self.get_rattail_app()
created = app.localtime(batch.created, from_utc=True)
executed = None
if batch.executed:
executed = app.localtime(batch.executed, from_utc=True)
return {
'uuid': batch.uuid,
'_str': str(batch),
'id': batch.id,
'id_str': batch.id_str,
'description': batch.description,
'notes': batch.notes,
'params': batch.params or {},
'rowcount': batch.rowcount,
'created': str(created),
'created_display': self.pretty_datetime(created),
'created_by_uuid': batch.created_by.uuid,
'created_by_display': str(batch.created_by),
'complete': batch.complete,
'status_code': batch.status_code,
'status_display': batch.STATUS.get(batch.status_code,
str(batch.status_code)),
'executed': str(executed) if executed else None,
'executed_display': self.pretty_datetime(executed) if executed else None,
'executed_by_uuid': batch.executed_by_uuid,
'executed_by_display': str(batch.executed_by or ''),
'mutable': self.batch_handler.is_mutable(batch),
}
def create_object(self, data):
"""
Create a new object instance and populate it with the given data.
Here we'll invoke the handler for actual batch creation, instead of
typical logic used for simple records.
"""
user = self.request.user
kwargs = dict(data)
kwargs['user'] = user
batch = self.batch_handler.make_batch(self.Session(), **kwargs)
if self.batch_handler.should_populate(batch):
self.batch_handler.do_populate(batch, user)
return batch
def update_object(self, batch, data):
"""
Logic for updating a main object record.
Here we want to make sure we set "created by" to the current user, when
creating a new batch.
"""
# we're only concerned with *new* batches here
if not batch.uuid:
# assign creator; initialize row count
batch.created_by_uuid = self.request.user.uuid
if batch.rowcount is None:
batch.rowcount = 0
# then go ahead with usual logic
return super(APIBatchView, self).update_object(batch, data)
def mark_complete(self):
"""
Mark the given batch as "complete".
"""
batch = self.get_object()
if batch.executed:
return {'error': "Batch {} has already been executed: {}".format(
batch.id_str, batch.description)}
if batch.complete:
return {'error': "Batch {} is already marked complete: {}".format(
batch.id_str, batch.description)}
batch.complete = True
return self._get(obj=batch)
def mark_incomplete(self):
"""
Mark the given batch as "incomplete".
"""
batch = self.get_object()
if batch.executed:
return {'error': "Batch {} has already been executed: {}".format(
batch.id_str, batch.description)}
if not batch.complete:
return {'error': "Batch {} is already marked incomplete: {}".format(
batch.id_str, batch.description)}
batch.complete = False
return self._get(obj=batch)
def execute(self):
"""
Execute the given batch.
"""
batch = self.get_object()
if batch.executed:
return {'error': "Batch {} has already been executed: {}".format(
batch.id_str, batch.description)}
kwargs = dict(self.request.json_body)
kwargs.pop('user', None)
kwargs.pop('progress', None)
result = self.batch_handler.do_execute(batch, self.request.user, **kwargs)
return {'ok': bool(result), 'batch': self.normalize(batch)}
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._batch_defaults(config)
@classmethod
def _batch_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
collection_url_prefix = cls.get_collection_url_prefix()
object_url_prefix = cls.get_object_url_prefix()
if cls.supports_toggle_complete:
# mark complete
mark_complete = Service(name='{}.mark_complete'.format(route_prefix),
path='{}/{{uuid}}/mark-complete'.format(object_url_prefix))
mark_complete.add_view('POST', 'mark_complete', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(mark_complete)
# mark incomplete
mark_incomplete = Service(name='{}.mark_incomplete'.format(route_prefix),
path='{}/{{uuid}}/mark-incomplete'.format(object_url_prefix))
mark_incomplete.add_view('POST', 'mark_incomplete', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(mark_incomplete)
if cls.supports_execute:
# execute batch
execute = Service(name='{}.execute'.format(route_prefix),
path='{}/{{uuid}}/execute'.format(object_url_prefix))
execute.add_view('POST', 'execute', klass=cls,
permission='{}.execute'.format(permission_prefix))
config.add_cornice_service(execute)
# TODO: deprecate / remove this
BatchAPIMasterView = APIBatchView
class APIBatchRowView(APIBatchMixin, APIMasterView):
"""
Base class for all API views which are meant to handle "batch rows" data.
"""
editable = False
supports_quick_entry = False
def __init__(self, request, **kwargs):
super(APIBatchRowView, self).__init__(request, **kwargs)
self.batch_handler = self.get_handler()
@property
def handler(self):
warnings.warn("the `handler` property is deprecated; "
"please use `batch_handler` instead",
DeprecationWarning, stacklevel=2)
return self.batch_handler
def normalize(self, row):
batch = row.batch
return {
'uuid': row.uuid,
'_str': str(row),
'_parent_str': str(batch),
'_parent_uuid': batch.uuid,
'batch_uuid': batch.uuid,
'batch_id': batch.id,
'batch_id_str': batch.id_str,
'batch_description': batch.description,
'batch_complete': batch.complete,
'batch_executed': bool(batch.executed),
'batch_mutable': self.batch_handler.is_mutable(batch),
'sequence': row.sequence,
'status_code': row.status_code,
'status_display': row.STATUS.get(row.status_code, str(row.status_code)),
}
def update_object(self, row, data):
"""
Supplements the default logic as follows:
Invokes the batch handler's ``refresh_row()`` method after updating the
row's field data per usual.
"""
if not self.batch_handler.is_mutable(row.batch):
return {'error': "Batch is not mutable"}
# update row per usual
row = super(APIBatchRowView, self).update_object(row, data)
# okay now we apply handler refresh logic
self.batch_handler.refresh_row(row)
return row
def delete_object(self, row):
"""
Overrides the default logic as follows:
Delegates deletion of the row to the batch handler.
"""
self.batch_handler.do_remove_row(row)
def quick_entry(self):
"""
View for handling "quick entry" user input, for a batch.
"""
data = self.request.json_body
uuid = data['batch_uuid']
batch = self.Session.get(self.get_batch_class(), uuid)
if not batch:
raise self.notfound()
entry = data['quick_entry']
try:
row = self.batch_handler.quick_entry(self.Session(), batch, entry)
except Exception as error:
log.warning("quick entry failed for '%s' batch %s: %s",
self.batch_handler.batch_key, batch.id_str, entry,
exc_info=True)
msg = str(error)
if not msg and isinstance(error, NotImplementedError):
msg = "Feature is not implemented"
return {'error': msg}
if not row:
return {'error': "Could not identify product"}
self.Session.flush()
result = self._get(obj=row)
result['ok'] = True
return result
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._batch_row_defaults(config)
@classmethod
def _batch_row_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
collection_url_prefix = cls.get_collection_url_prefix()
if cls.supports_quick_entry:
# quick entry
quick_entry = Service(name='{}.quick_entry'.format(route_prefix),
path='{}/quick-entry'.format(collection_url_prefix))
quick_entry.add_view('POST', 'quick_entry', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(quick_entry)

View file

@ -1,200 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Inventory Batches
"""
import decimal
import sqlalchemy as sa
from rattail import pod
from rattail.db.model import InventoryBatch, InventoryBatchRow
from cornice import Service
from tailbone.api.batch import APIBatchView, APIBatchRowView
class InventoryBatchViews(APIBatchView):
model_class = InventoryBatch
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
route_prefix = 'inventory'
permission_prefix = 'batch.inventory'
collection_url_prefix = '/inventory-batches'
object_url_prefix = '/inventory-batch'
supports_toggle_complete = True
def normalize(self, batch):
data = super().normalize(batch)
data['mode'] = batch.mode
data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode)
if data['mode_display'] is None and batch.mode is not None:
data['mode_display'] = str(batch.mode)
data['reason_code'] = batch.reason_code
return data
def count_modes(self):
"""
Retrieve info about the available batch count modes.
"""
permission_prefix = self.get_permission_prefix()
if self.request.is_root:
modes = self.batch_handler.get_count_modes()
else:
modes = self.batch_handler.get_allowed_count_modes(
self.Session(), self.request.user,
permission_prefix=permission_prefix)
return modes
def adjustment_reasons(self):
"""
Retrieve info about the available "reasons" for inventory adjustment
batches.
"""
raw_reasons = self.batch_handler.get_adjustment_reasons(self.Session())
reasons = []
for reason in raw_reasons:
reasons.append({
'uuid': reason.uuid,
'code': reason.code,
'description': reason.description,
'hidden': reason.hidden,
})
return reasons
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._batch_defaults(config)
cls._inventory_defaults(config)
@classmethod
def _inventory_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
collection_url_prefix = cls.get_collection_url_prefix()
# get count modes
count_modes = Service(name='{}.count_modes'.format(route_prefix),
path='{}/count-modes'.format(collection_url_prefix))
count_modes.add_view('GET', 'count_modes', klass=cls,
permission='{}.list'.format(permission_prefix))
config.add_cornice_service(count_modes)
# get adjustment reasons
adjustment_reasons = Service(name='{}.adjustment_reasons'.format(route_prefix),
path='{}/adjustment-reasons'.format(collection_url_prefix))
adjustment_reasons.add_view('GET', 'adjustment_reasons', klass=cls,
permission='{}.list'.format(permission_prefix))
config.add_cornice_service(adjustment_reasons)
class InventoryBatchRowViews(APIBatchRowView):
model_class = InventoryBatchRow
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
route_prefix = 'inventory.rows'
permission_prefix = 'batch.inventory'
collection_url_prefix = '/inventory-batch-rows'
object_url_prefix = '/inventory-batch-row'
editable = True
supports_quick_entry = True
def normalize(self, row):
batch = row.batch
data = super().normalize(row)
app = self.get_rattail_app()
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description
data['size'] = row.size
data['full_description'] = row.product.full_description if row.product else row.description
data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
data['case_quantity'] = app.render_quantity(row.case_quantity or 1)
data['cases'] = row.cases
data['units'] = row.units
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
data['quantity_display'] = "{} {}".format(
app.render_quantity(row.cases or row.units),
'CS' if row.cases else data['unit_uom'])
data['allow_cases'] = self.batch_handler.allow_cases(batch)
return data
def update_object(self, row, data):
"""
Supplements the default logic as follows:
Converts certain fields within the data, to proper "native" types.
"""
data = dict(data)
# convert some data types as needed
if 'cases' in data:
if data['cases'] == '':
data['cases'] = None
elif data['cases']:
data['cases'] = decimal.Decimal(data['cases'])
if 'units' in data:
if data['units'] == '':
data['units'] = None
elif data['units']:
data['units'] = decimal.Decimal(data['units'])
# update row per usual
try:
row = super().update_object(row, data)
except sa.exc.DataError as error:
# detect when user scans barcode for cases/units field
if hasattr(error, 'orig'):
orig = type(error.orig)
if hasattr(orig, '__name__'):
# nb. this particular error is from psycopg2
if orig.__name__ == 'NumericValueOutOfRange':
return {'error': "Numeric value out of range"}
raise
return row
def defaults(config, **kwargs):
base = globals()
InventoryBatchViews = kwargs.get('InventoryBatchViews', base['InventoryBatchViews'])
InventoryBatchViews.defaults(config)
InventoryBatchRowViews = kwargs.get('InventoryBatchRowViews', base['InventoryBatchRowViews'])
InventoryBatchRowViews.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,78 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Label Batches
"""
from rattail.db import model
from tailbone.api.batch import APIBatchView, APIBatchRowView
class LabelBatchViews(APIBatchView):
model_class = model.LabelBatch
default_handler_spec = 'rattail.batch.labels:LabelBatchHandler'
route_prefix = 'labelbatchviews'
permission_prefix = 'labels.batch'
collection_url_prefix = '/label-batches'
object_url_prefix = '/label-batch'
supports_toggle_complete = True
class LabelBatchRowViews(APIBatchRowView):
model_class = model.LabelBatchRow
default_handler_spec = 'rattail.batch.labels:LabelBatchHandler'
route_prefix = 'api.label_batch_rows'
permission_prefix = 'labels.batch'
collection_url_prefix = '/label-batch-rows'
object_url_prefix = '/label-batch-row'
supports_quick_entry = True
def normalize(self, row):
batch = row.batch
data = super().normalize(row)
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description
data['size'] = row.size
data['full_description'] = row.product.full_description if row.product else row.description
return data
def defaults(config, **kwargs):
base = globals()
LabelBatchViews = kwargs.get('LabelBatchViews', base['LabelBatchViews'])
LabelBatchViews.defaults(config)
LabelBatchRowViews = kwargs.get('LabelBatchRowViews', base['LabelBatchRowViews'])
LabelBatchRowViews.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,318 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Ordering Batches
These views expose the basic CRUD interface to "ordering" batches, for the web
API.
"""
import datetime
import logging
import sqlalchemy as sa
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
from cornice import Service
from tailbone.api.batch import APIBatchView, APIBatchRowView
log = logging.getLogger(__name__)
class OrderingBatchViews(APIBatchView):
model_class = PurchaseBatch
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'orderingbatchviews'
permission_prefix = 'ordering'
collection_url_prefix = '/ordering-batches'
object_url_prefix = '/ordering-batch'
supports_toggle_complete = True
supports_execute = True
def base_query(self):
"""
Modifies the default logic as follows:
Adds a condition to the query, to ensure only purchase batches with
"ordering" mode are returned.
"""
model = self.model
query = super().base_query()
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING)
return query
def normalize(self, batch):
data = super().normalize(batch)
data['vendor_uuid'] = batch.vendor.uuid
data['vendor_display'] = str(batch.vendor)
data['department_uuid'] = batch.department_uuid
data['department_display'] = str(batch.department) if batch.department else None
data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0)
data['ship_method'] = batch.ship_method
data['notes_to_vendor'] = batch.notes_to_vendor
return data
def create_object(self, data):
"""
Modifies the default logic as follows:
Sets the mode to "ordering" for the new batch.
"""
data = dict(data)
if not data.get('vendor_uuid'):
raise ValueError("You must specify the vendor")
data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
batch = super().create_object(data)
return batch
def worksheet(self):
"""
Returns primary data for the Ordering Worksheet view.
"""
batch = self.get_object()
if batch.executed:
raise self.forbidden()
app = self.get_rattail_app()
# TODO: much of the logic below was copied from the traditional master
# view for ordering batches. should maybe let them share it somehow?
# organize existing batch rows by product
order_items = {}
for row in batch.active_rows():
order_items[row.product_uuid] = row
# organize vendor catalog costs by dept / subdept
departments = {}
costs = self.batch_handler.get_order_form_costs(self.Session(), batch.vendor)
costs = self.batch_handler.sort_order_form_costs(costs)
costs = list(costs) # we must have a stable list for the rest of this
self.batch_handler.decorate_order_form_costs(batch, costs)
for cost in costs:
department = cost.product.department
if department:
department_dict = departments.setdefault(department.uuid, {
'uuid': department.uuid,
'number': department.number,
'name': department.name,
})
else:
if None not in departments:
departments[None] = {
'uuid': None,
'number': None,
'name': "",
}
department_dict = departments[None]
subdepartments = department_dict.setdefault('subdepartments', {})
subdepartment = cost.product.subdepartment
if subdepartment:
subdepartment_dict = subdepartments.setdefault(subdepartment.uuid, {
'uuid': subdepartment.uuid,
'number': subdepartment.number,
'name': subdepartment.name,
})
else:
if None not in subdepartments:
subdepartments[None] = {
'uuid': None,
'number': None,
'name': "",
}
subdepartment_dict = subdepartments[None]
subdept_costs = subdepartment_dict.setdefault('costs', [])
product = cost.product
subdept_costs.append({
'uuid': cost.uuid,
'upc': str(product.upc),
'upc_pretty': product.upc.pretty() if product.upc else None,
'brand_name': product.brand.name if product.brand else None,
'description': product.description,
'size': product.size,
'case_size': cost.case_size,
'uom_display': "LB" if product.weighed else "EA",
'vendor_item_code': cost.code,
'preference': cost.preference,
'preferred': cost.preference == 1,
'unit_cost': cost.unit_cost,
'unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost.unit_cost is not None else "",
# TODO
# 'cases_ordered': None,
# 'units_ordered': None,
# 'po_total': None,
# 'po_total_display': None,
})
# sort the (sub)department groupings
sorted_departments = []
for dept in sorted(departments.values(), key=lambda d: d['name']):
dept['subdepartments'] = sorted(dept['subdepartments'].values(),
key=lambda s: s['name'])
sorted_departments.append(dept)
# fetch recent purchase history, sort/pad for template convenience
history = self.batch_handler.get_order_form_history(batch, costs, 6)
for i in range(6 - len(history)):
history.append(None)
history = list(reversed(history))
# must convert some date objects to string, for JSON sake
for h in history:
if not h:
continue
purchase = h.get('purchase')
if purchase:
dt = purchase.get('date_ordered')
if dt and isinstance(dt, datetime.date):
purchase['date_ordered'] = app.render_date(dt)
dt = purchase.get('date_received')
if dt and isinstance(dt, datetime.date):
purchase['date_received'] = app.render_date(dt)
return {
'batch': self.normalize(batch),
'departments': departments,
'sorted_departments': sorted_departments,
'history': history,
}
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._batch_defaults(config)
cls._ordering_batch_defaults(config)
@classmethod
def _ordering_batch_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
object_url_prefix = cls.get_object_url_prefix()
# worksheet
worksheet = Service(name='{}.worksheet'.format(route_prefix),
path='{}/{{uuid}}/worksheet'.format(object_url_prefix))
worksheet.add_view('GET', 'worksheet', klass=cls,
permission='{}.worksheet'.format(permission_prefix))
config.add_cornice_service(worksheet)
class OrderingBatchRowViews(APIBatchRowView):
model_class = PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'ordering.rows'
permission_prefix = 'ordering'
collection_url_prefix = '/ordering-batch-rows'
object_url_prefix = '/ordering-batch-row'
supports_quick_entry = True
editable = True
def normalize(self, row):
data = super().normalize(row)
app = self.get_rattail_app()
batch = row.batch
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description
data['size'] = row.size
data['full_description'] = row.product.full_description if row.product else row.description
# # only provide image url if so configured
# if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
# data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
# unit_uom can vary by product
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
data['case_quantity'] = row.case_quantity
data['cases_ordered'] = row.cases_ordered
data['units_ordered'] = row.units_ordered
data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False)
data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False)
data['po_unit_cost'] = row.po_unit_cost
data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None
data['po_total_calculated'] = row.po_total_calculated
data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None
data['status_code'] = row.status_code
data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code))
return data
def update_object(self, row, data):
"""
Overrides the default logic as follows:
So far, we only allow updating the ``cases_ordered`` and/or
``units_ordered`` quantities; therefore ``data`` should have one or
both of those keys.
This data is then passed to the
:meth:`~rattail:rattail.batch.purchase.PurchaseBatchHandler.update_row_quantity()`
method of the batch handler.
Note that the "normal" logic for this method is not invoked at all.
"""
if not self.batch_handler.is_mutable(row.batch):
return {'error': "Batch is not mutable"}
try:
self.batch_handler.update_row_quantity(row, **data)
self.Session.flush()
except Exception as error:
log.warning("update_row_quantity failed", exc_info=True)
if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
error = str(error.orig)
else:
error = str(error)
return {'error': error}
return row
def defaults(config, **kwargs):
base = globals()
OrderingBatchViews = kwargs.get('OrderingBatchViews', base['OrderingBatchViews'])
OrderingBatchViews.defaults(config)
OrderingBatchRowViews = kwargs.get('OrderingBatchRowViews', base['OrderingBatchRowViews'])
OrderingBatchRowViews.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,492 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Receiving Batches
"""
import logging
import humanize
import sqlalchemy as sa
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
from cornice import Service
from deform import widget as dfwidget
from tailbone import forms
from tailbone.api.batch import APIBatchView, APIBatchRowView
from tailbone.forms.receiving import ReceiveRow
log = logging.getLogger(__name__)
class ReceivingBatchViews(APIBatchView):
model_class = PurchaseBatch
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receivingbatchviews'
permission_prefix = 'receiving'
collection_url_prefix = '/receiving-batches'
object_url_prefix = '/receiving-batch'
supports_toggle_complete = True
supports_execute = True
def base_query(self):
model = self.app.model
query = super().base_query()
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
return query
def normalize(self, batch):
data = super().normalize(batch)
data['vendor_uuid'] = batch.vendor.uuid
data['vendor_display'] = str(batch.vendor)
data['department_uuid'] = batch.department_uuid
data['department_display'] = str(batch.department) if batch.department else None
data['po_number'] = batch.po_number
data['po_total'] = batch.po_total
data['invoice_total'] = batch.invoice_total
data['invoice_total_calculated'] = batch.invoice_total_calculated
data['can_auto_receive'] = self.batch_handler.can_auto_receive(batch)
return data
def create_object(self, data):
data = dict(data)
# all about receiving mode here
data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING
# assume "receive from PO" if given a PO key
if data.get('purchase_key'):
data['workflow'] = 'from_po'
return super().create_object(data)
def auto_receive(self):
"""
View which handles auto-marking as received, all items within
a pending batch.
"""
batch = self.get_object()
self.batch_handler.auto_receive_all_items(batch)
return self._get(obj=batch)
def mark_receiving_complete(self):
"""
Mark the given batch as "receiving complete".
"""
batch = self.get_object()
if batch.executed:
return {'error': "Batch {} has already been executed: {}".format(
batch.id_str, batch.description)}
if batch.complete:
return {'error': "Batch {} is already marked complete: {}".format(
batch.id_str, batch.description)}
if batch.receiving_complete:
return {'error': "Receiving is already complete for batch {}: {}".format(
batch.id_str, batch.description)}
batch.receiving_complete = True
return self._get(obj=batch)
def eligible_purchases(self):
model = self.app.model
uuid = self.request.params.get('vendor_uuid')
vendor = self.Session.get(model.Vendor, uuid) if uuid else None
if not vendor:
return {'error': "Vendor not found"}
purchases = self.batch_handler.get_eligible_purchases(
vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING)
purchases = [self.normalize_eligible_purchase(p)
for p in purchases]
return {'purchases': purchases}
def normalize_eligible_purchase(self, purchase):
return self.batch_handler.normalize_eligible_purchase(purchase)
def render_eligible_purchase(self, purchase):
return self.batch_handler.render_eligible_purchase(purchase)
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._batch_defaults(config)
cls._receiving_batch_defaults(config)
@classmethod
def _receiving_batch_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
collection_url_prefix = cls.get_collection_url_prefix()
object_url_prefix = cls.get_object_url_prefix()
# auto_receive
auto_receive = Service(name='{}.auto_receive'.format(route_prefix),
path='{}/{{uuid}}/auto-receive'.format(object_url_prefix))
auto_receive.add_view('GET', 'auto_receive', klass=cls,
permission='{}.auto_receive'.format(permission_prefix))
config.add_cornice_service(auto_receive)
# mark_receiving_complete
mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix),
path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix))
mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(mark_receiving_complete)
# eligible purchases
eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix),
path='{}/eligible-purchases'.format(collection_url_prefix))
eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls,
permission='{}.create'.format(permission_prefix))
config.add_cornice_service(eligible_purchases)
class ReceivingBatchRowViews(APIBatchRowView):
model_class = PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receiving.rows'
permission_prefix = 'receiving'
collection_url_prefix = '/receiving-batch-rows'
object_url_prefix = '/receiving-batch-row'
supports_quick_entry = True
def make_filter_spec(self):
model = self.app.model
filters = super().make_filter_spec()
if filters:
# must translate certain convenience filters
orig_filters, filters = filters, []
for filtr in orig_filters:
# # is_received
# # NOTE: this is only relevant for truck dump or "from scratch"
# if filtr['field'] == 'is_received' and filtr['op'] == 'eq' and filtr['value'] is True:
# filters.extend([
# {'or': [
# {'field': 'cases_received', 'op': '!=', 'value': 0},
# {'field': 'units_received', 'op': '!=', 'value': 0},
# ]},
# ])
# is_incomplete
if filtr['field'] == 'is_incomplete' and filtr['op'] == 'eq' and filtr['value'] is True:
# looking for any rows with "ordered" quantity, but where the
# status does *not* signify a "settled" row so to speak
# TODO: would be nice if we had a simple flag to leverage?
filters.extend([
{'or': [
{'field': 'cases_ordered', 'op': '!=', 'value': 0},
{'field': 'units_ordered', 'op': '!=', 'value': 0},
]},
{'field': 'status_code', 'op': 'not_in', 'value': [
model.PurchaseBatchRow.STATUS_OK,
model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS,
]},
])
# is_invalid
elif filtr['field'] == 'is_invalid' and filtr['op'] == 'eq' and filtr['value'] is True:
filters.extend([
{'field': 'status_code', 'op': 'in', 'value': [
model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
model.PurchaseBatchRow.STATUS_COST_NOT_FOUND,
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN,
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS,
]},
])
# is_unexpected
elif filtr['field'] == 'is_unexpected' and filtr['op'] == 'eq' and filtr['value'] is True:
# looking for any rows which do *not* have "ordered/shipped" quantity
filters.extend([
{'and': [
{'or': [
{'field': 'cases_ordered', 'op': 'is_null'},
{'field': 'cases_ordered', 'op': '==', 'value': 0},
]},
{'or': [
{'field': 'units_ordered', 'op': 'is_null'},
{'field': 'units_ordered', 'op': '==', 'value': 0},
]},
{'or': [
{'field': 'cases_shipped', 'op': 'is_null'},
{'field': 'cases_shipped', 'op': '==', 'value': 0},
]},
{'or': [
{'field': 'units_shipped', 'op': 'is_null'},
{'field': 'units_shipped', 'op': '==', 'value': 0},
]},
{'or': [
# but "unexpected" also implies we have some confirmed amount(s)
{'field': 'cases_received', 'op': '!=', 'value': 0},
{'field': 'units_received', 'op': '!=', 'value': 0},
{'field': 'cases_damaged', 'op': '!=', 'value': 0},
{'field': 'units_damaged', 'op': '!=', 'value': 0},
{'field': 'cases_expired', 'op': '!=', 'value': 0},
{'field': 'units_expired', 'op': '!=', 'value': 0},
]},
]},
])
# is_damaged
elif filtr['field'] == 'is_damaged' and filtr['op'] == 'eq' and filtr['value'] is True:
filters.extend([
{'or': [
{'field': 'cases_damaged', 'op': '!=', 'value': 0},
{'field': 'units_damaged', 'op': '!=', 'value': 0},
]},
])
# is_expired
elif filtr['field'] == 'is_expired' and filtr['op'] == 'eq' and filtr['value'] is True:
filters.extend([
{'or': [
{'field': 'cases_expired', 'op': '!=', 'value': 0},
{'field': 'units_expired', 'op': '!=', 'value': 0},
]},
])
# is_missing
elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True:
filters.extend([
{'or': [
{'field': 'cases_missing', 'op': '!=', 'value': 0},
{'field': 'units_missing', 'op': '!=', 'value': 0},
]},
])
else: # just some filter, use as-is
filters.append(filtr)
return filters
def normalize(self, row):
data = super().normalize(row)
model = self.app.model
batch = row.batch
prodder = self.app.get_products_handler()
data['product_uuid'] = row.product_uuid
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description
data['size'] = row.size
data['full_description'] = row.product.full_description if row.product else row.description
# only provide image url if so configured
if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
data['image_url'] = prodder.get_image_url(product=row.product, upc=row.upc)
# unit_uom can vary by product
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
data['case_quantity'] = row.case_quantity
data['order_quantities_known'] = batch.order_quantities_known
data['cases_ordered'] = row.cases_ordered
data['units_ordered'] = row.units_ordered
data['cases_shipped'] = row.cases_shipped
data['units_shipped'] = row.units_shipped
data['cases_received'] = row.cases_received
data['units_received'] = row.units_received
data['cases_damaged'] = row.cases_damaged
data['units_damaged'] = row.units_damaged
data['cases_expired'] = row.cases_expired
data['units_expired'] = row.units_expired
data['cases_missing'] = row.cases_missing
data['units_missing'] = row.units_missing
cases, units = self.batch_handler.get_unconfirmed_counts(row)
data['cases_unconfirmed'] = cases
data['units_unconfirmed'] = units
data['po_unit_cost'] = row.po_unit_cost
data['po_total'] = row.po_total
data['invoice_number'] = row.invoice_number
data['invoice_unit_cost'] = row.invoice_unit_cost
data['invoice_total'] = row.invoice_total
data['invoice_total_calculated'] = row.invoice_total_calculated
data['allow_cases'] = self.batch_handler.allow_cases()
data['quick_receive'] = self.rattail_config.getbool(
'rattail.batch', 'purchase.mobile_quick_receive',
default=True)
if batch.order_quantities_known:
data['quick_receive_all'] = self.rattail_config.getbool(
'rattail.batch', 'purchase.mobile_quick_receive_all',
default=False)
# TODO: this was copied from regular view receive_row() method; should merge
if data['quick_receive'] and data.get('quick_receive_all'):
if data['allow_cases']:
data['quick_receive_uom'] = 'CS'
raise NotImplementedError("TODO: add CS support for quick_receive_all")
else:
data['quick_receive_uom'] = data['unit_uom']
accounted_for = self.batch_handler.get_units_accounted_for(row)
remainder = self.batch_handler.get_units_ordered(row) - accounted_for
if accounted_for:
# some product accounted for; button should receive "remainder" only
if remainder:
remainder = self.app.render_quantity(remainder)
data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive Remainder ({} {})".format(
remainder, data['unit_uom'])
else:
# unless there is no remainder, in which case disable it
data['quick_receive'] = False
else: # nothing yet accounted for, button should receive "all"
if not remainder:
log.warning("quick receive remainder is empty for row %s", row.uuid)
remainder = self.app.render_quantity(remainder)
data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive ALL ({} {})".format(
remainder, data['unit_uom'])
data['unexpected_alert'] = None
if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
warn = True
if batch.is_truck_dump_parent() and row.product:
uuids = [child.uuid for child in batch.truck_dump_children]
if uuids:
count = self.Session.query(model.PurchaseBatchRow)\
.filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
.filter(model.PurchaseBatchRow.product == row.product)\
.count()
if count:
warn = False
if warn:
data['unexpected_alert'] = "This item was NOT on the original purchase order."
# TODO: surely the caller of API should determine this flag?
# maybe alert user if they've already received some of this product
alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
default=False)
if alert_received:
data['received_alert'] = None
if self.batch_handler.get_units_confirmed(row):
msg = "You have already received some of this product; last update was {}.".format(
humanize.naturaltime(self.app.make_utc() - row.modified))
data['received_alert'] = msg
return data
def receive(self):
"""
View which handles "receiving" against a particular batch row.
"""
model = self.app.model
# first do basic input validation
schema = ReceiveRow().bind(session=self.Session())
form = forms.Form(schema=schema, request=self.request)
# TODO: this seems hacky, but avoids "complex" date value parsing
form.set_widget('expiration_date', dfwidget.TextInputWidget())
if not form.validate():
log.warning("form did not validate: %s",
form.make_deform_form().error)
return {'error': "Form did not validate"}
# fetch / validate row object
row = self.Session.get(model.PurchaseBatchRow, form.validated['row'])
if row is not self.get_object():
return {'error': "Specified row does not match the route!"}
# handler takes care of the row receiving logic for us
kwargs = dict(form.validated)
del kwargs['row']
try:
self.batch_handler.receive_row(row, **kwargs)
self.Session.flush()
except Exception as error:
log.warning("receive() failed", exc_info=True)
if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
error = str(error.orig)
else:
error = str(error)
return {'error': error}
return self._get(obj=row)
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._batch_row_defaults(config)
cls._receiving_batch_row_defaults(config)
@classmethod
def _receiving_batch_row_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
object_url_prefix = cls.get_object_url_prefix()
# receive (row)
receive = Service(name='{}.receive'.format(route_prefix),
path='{}/{{uuid}}/receive'.format(object_url_prefix))
receive.add_view('POST', 'receive', klass=cls,
permission='{}.edit_row'.format(permission_prefix))
config.add_cornice_service(receive)
def defaults(config, **kwargs):
base = globals()
ReceivingBatchViews = kwargs.get('ReceivingBatchViews', base['ReceivingBatchViews'])
ReceivingBatchViews.defaults(config)
ReceivingBatchRowViews = kwargs.get('ReceivingBatchRowViews', base['ReceivingBatchRowViews'])
ReceivingBatchRowViews.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,159 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - "Common" Views
"""
from collections import OrderedDict
from rattail.util import get_pkg_version
from cornice import Service
from cornice.service import get_services
from cornice_swagger import CorniceSwagger
from tailbone import forms
from tailbone.forms.common import Feedback
from tailbone.api import APIView, api
from tailbone.db import Session
class CommonView(APIView):
"""
Misc. "common" views for the API.
.. attribute:: feedback_email_key
This is the email key which will be used when sending "user feedback"
email. Default value is ``'user_feedback'``.
"""
feedback_email_key = 'user_feedback'
@api
def about(self):
"""
Generic view to show "about project" info page.
"""
packages = self.get_packages()
return {
'project_title': self.get_project_title(),
'project_version': self.get_project_version(),
'packages': packages,
'package_names': list(packages),
}
def get_project_title(self):
app = self.get_rattail_app()
return app.get_title()
def get_project_version(self):
app = self.get_rattail_app()
return app.get_version()
def get_packages(self):
"""
Should return the full set of packages which should be displayed on the
'about' page.
"""
return OrderedDict([
('rattail', get_pkg_version('rattail')),
('Tailbone', get_pkg_version('Tailbone')),
])
@api
def feedback(self):
"""
View to handle user feedback form submits.
"""
app = self.get_rattail_app()
model = self.model
# TODO: this logic was copied from tailbone.views.common and is largely
# identical; perhaps should merge somehow?
schema = Feedback().bind(session=Session())
form = forms.Form(schema=schema, request=self.request)
if form.validate():
data = dict(form.validated)
# figure out who the sending user is, if any
if self.request.user:
data['user'] = self.request.user
elif data['user']:
data['user'] = Session.get(model.User, data['user'])
# TODO: should provide URL to view user
if data['user']:
data['user_url'] = '#' # TODO: could get from config?
data['client_ip'] = self.request.client_addr
email_key = data['email_key'] or self.feedback_email_key
app.send_email(email_key, data=data)
return {'ok': True}
return {'error': "Form did not validate!"}
def swagger(self):
doc = CorniceSwagger(get_services())
app = self.get_rattail_app()
spec = doc.generate(f"{app.get_node_title()} API docs",
app.get_version(),
base_path='/api') # TODO
return spec
@classmethod
def defaults(cls, config):
cls._common_defaults(config)
@classmethod
def _common_defaults(cls, config):
rattail_config = config.registry.settings.get('rattail_config')
app = rattail_config.get_app()
# about
about = Service(name='about', path='/about')
about.add_view('GET', 'about', klass=cls)
config.add_cornice_service(about)
# feedback
feedback = Service(name='feedback', path='/feedback')
feedback.add_view('POST', 'feedback', klass=cls,
permission='common.feedback')
config.add_cornice_service(feedback)
# swagger
swagger = Service(name='swagger',
path='/swagger.json',
description=f"OpenAPI documentation for {app.get_title()}")
swagger.add_view('GET', 'swagger', klass=cls,
permission='common.api_swagger')
config.add_cornice_service(swagger)
def defaults(config, **kwargs):
base = globals()
CommonView = kwargs.get('CommonView', base['CommonView'])
CommonView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,125 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Core Views
"""
from tailbone.views import View
def api(view_meth):
"""
Common decorator for all API views. Ideally this would not be needed..but
for now, alas, it is.
"""
def wrapped(view, *args, **kwargs):
# TODO: why doesn't this work here...? (instead we have to repeat this
# code in lots of other places)
# if view.request.method == 'OPTIONS':
# return view.request.response
# invoke the view logic first, since presumably it may involve a
# redirect in which case we don't really need to add the CSRF token.
# main known use case for this is the /logout endpoint - if that gets
# hit then the "current" (old) session will be destroyed, in which case
# we can't use the token from that, but instead must generate a new one.
result = view_meth(view, *args, **kwargs)
# explicitly set CSRF token cookie, unless OPTIONS request
# TODO: why doesn't pyramid do this for us again?
if view.request.method != 'OPTIONS':
view.request.response.set_cookie(name='XSRF-TOKEN',
value=view.request.session.get_csrf_token())
return result
return wrapped
class APIView(View):
"""
Base class for all API views.
"""
def pretty_datetime(self, dt):
if not dt:
return ""
return dt.strftime('%Y-%m-%d @ %I:%M %p')
def get_user_info(self, user):
"""
This method is present on *all* API views, and is meant to provide a
single means of obtaining "common" user info, for return to the caller.
Such info may be returned in several places, e.g. upon login but also
in the "check session" call, or e.g. as part of a broader return value
from any other call.
:returns: Dictionary of user info data, ready for JSON serialization.
Note that you should *not* (usually) override this method in any view,
but instead configure a "supplemental" function which can then add or
replace info entries. Config for that looks like e.g.:
.. code-block:: ini
[tailbone.api]
extra_user_info = poser.web.api.util:extra_user_info
Note that the above config assumes a simple *function* defined in your
``util`` module; such a function would look like e.g.::
def extra_user_info(request, user, **info):
# add favorite color
info['favorite_color'] = 'green'
# override display name
info['display_name'] = "TODO"
# remove short_name
info.pop('short_name', None)
return info
"""
app = self.get_rattail_app()
auth = app.get_auth_handler()
# basic / default info
is_admin = auth.user_is_admin(user)
employee = app.get_employee(user)
info = {
'uuid': user.uuid,
'username': user.username,
'display_name': user.display_name,
'short_name': auth.get_short_display_name(user),
'is_admin': is_admin,
'is_root': is_admin and self.request.session.get('is_root', False),
'employee_uuid': employee.uuid if employee else None,
'email_address': app.get_contact_email_address(user),
}
# maybe get/use "extra" info
extra = self.rattail_config.get('tailbone.api', 'extra_user_info',
usedb=False)
if extra:
extra = app.load_object(extra)
info = extra(self.request, user, **info)
return info

View file

@ -1,60 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Customer Views
"""
from rattail.db import model
from tailbone.api import APIMasterView
class CustomerView(APIMasterView):
"""
API views for Customer data
"""
model_class = model.Customer
collection_url_prefix = '/customers'
object_url_prefix = '/customer'
supports_autocomplete = True
autocomplete_fieldname = 'name'
def normalize(self, customer):
return {
'uuid': customer.uuid,
'_str': str(customer),
'id': customer.id,
'number': customer.number,
'name': customer.name,
}
def defaults(config, **kwargs):
base = globals()
CustomerView = kwargs.get('CustomerView', base['CustomerView'])
CustomerView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,36 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Essential views for convenient includes
"""
def defaults(config, **kwargs):
mod = lambda spec: kwargs.get(spec, spec)
config.include(mod('tailbone.api.auth'))
config.include(mod('tailbone.api.common'))
def includeme(config):
defaults(config)

View file

@ -1,51 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Label Views
"""
from __future__ import unicode_literals, absolute_import
from rattail.db.model import LabelProfile
from tailbone.api import APIMasterView
class LabelProfileView(APIMasterView):
"""
API views for Label Profile data
"""
model_class = LabelProfile
collection_url_prefix = '/label-profiles'
object_url_prefix = '/label-profile'
def defaults(config, **kwargs):
base = globals()
LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView'])
LabelProfileView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,618 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Master View
"""
import json
from rattail.db.util import get_fieldnames
from cornice import resource, Service
from tailbone.api import APIView
from tailbone.db import Session
from tailbone.util import SortColumn
class APIMasterView(APIView):
"""
Base class for data model REST API views.
"""
listable = True
creatable = True
viewable = True
editable = True
deletable = True
supports_autocomplete = False
supports_download = False
supports_rawbytes = False
@property
def Session(self):
return Session
@classmethod
def get_model_class(cls):
if hasattr(cls, 'model_class'):
return cls.model_class
raise NotImplementedError("must set `model_class` for {}".format(cls.__name__))
@classmethod
def get_normalized_model_name(cls):
if hasattr(cls, 'normalized_model_name'):
return cls.normalized_model_name
return cls.get_model_class().__name__.lower()
@classmethod
def get_route_prefix(cls):
"""
Returns a prefix which (by default) applies to all routes provided by
this view class.
"""
prefix = getattr(cls, 'route_prefix', None)
if prefix:
return prefix
model_name = cls.get_normalized_model_name()
return '{}s'.format(model_name)
@classmethod
def get_permission_prefix(cls):
"""
Returns a prefix which (by default) applies to all permissions
leveraged by this view class.
"""
prefix = getattr(cls, 'permission_prefix', None)
if prefix:
return prefix
return cls.get_route_prefix()
@classmethod
def get_collection_url_prefix(cls):
"""
Returns a prefix which (by default) applies to all "collection" URLs
provided by this view class.
"""
prefix = getattr(cls, 'collection_url_prefix', None)
if prefix:
return prefix
return '/{}'.format(cls.get_route_prefix())
@classmethod
def get_object_url_prefix(cls):
"""
Returns a prefix which (by default) applies to all "object" URLs
provided by this view class.
"""
prefix = getattr(cls, 'object_url_prefix', None)
if prefix:
return prefix
return '/{}'.format(cls.get_route_prefix())
@classmethod
def get_object_key(cls):
if hasattr(cls, 'object_key'):
return cls.object_key
return cls.get_normalized_model_name()
@classmethod
def get_collection_key(cls):
if hasattr(cls, 'collection_key'):
return cls.collection_key
return '{}s'.format(cls.get_object_key())
@classmethod
def establish_method(cls, method_name):
"""
Establish the given HTTP method for this Cornice Resource.
Cornice will auto-register any class methods for a resource, if they
are named according to what it expects (i.e. 'get', 'collection_get'
etc.). Tailbone API tries to make things automagical for the sake of
e.g. Poser logic, but in this case if we predefine all of these methods
and then some subclass view wants to *not* allow one, it's not clear
how to "undefine" it per se. Or at least, the more straightforward
thing (I think) is to not define such a method in the first place, if
it was not wanted.
Enter ``establish_method()``, which is what finally "defines" each
resource method according to what the subclass has declared via its
various attributes (:attr:`creatable`, :attr:`deletable` etc.).
Note that you will not likely have any need to use this
``establish_method()`` yourself! But we describe its purpose here, for
clarity.
"""
def method(self):
internal_method = getattr(self, '_{}'.format(method_name))
return internal_method()
setattr(cls, method_name, method)
def make_filter_spec(self):
if not self.request.GET.has_key('filters'):
return []
filters = json.loads(self.request.GET.getone('filters'))
return filters
def make_sort_spec(self):
# we prefer a "native sort"
if self.request.GET.has_key('nativeSort'):
return json.loads(self.request.GET.getone('nativeSort'))
# these params are based on 'vuetable-2'
# https://www.vuetable.com/guide/sorting.html#initial-sorting-order
if 'sort' in self.request.params:
sort = self.request.params['sort']
sortkey, sortdir = sort.split('|')
if sortdir != 'desc':
sortdir = 'asc'
return [
{
# 'model': self.model_class.__name__,
'field': sortkey,
'direction': sortdir,
},
]
# these params are based on 'vue-tables-2'
# https://github.com/matfish2/vue-tables-2#server-side
if 'orderBy' in self.request.params and 'ascending' in self.request.params:
sortcol = self.interpret_sortcol(self.request.params['orderBy'])
if sortcol:
spec = {
'field': sortcol.field_name,
'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
}
if sortcol.model_name:
spec['model'] = sortcol.model_name
return [spec]
def interpret_sortcol(self, order_by):
"""
This must return a ``SortColumn`` object based on parsing of the given
``order_by`` string, which is "raw" as received from the client.
Please override as necessary, but in all cases you should invoke
:meth:`sortcol()` to obtain your return value. Default behavior
for this method is to simply do (only) that::
return self.sortcol(order_by)
Note that you can also return ``None`` here, if the given ``order_by``
string does not represent a valid sort.
"""
return self.sortcol(order_by)
def sortcol(self, field_name, model_name=None):
"""
Return a simple ``SortColumn`` object which denotes the field and
optionally, the model, to be used when sorting.
"""
if not model_name:
model_name = self.model_class.__name__
return SortColumn(field_name, model_name)
def join_for_sort_spec(self, query, sort_spec):
"""
This should apply any joins needed on the given query, to accommodate
requested sorting as per ``sort_spec`` - which will be non-empty but
otherwise no claims are made regarding its contents.
Please override as necessary, but in all cases you should return a
query, either untouched or else with join(s) applied.
"""
model_name = sort_spec[0].get('model')
return self.join_for_sort_model(query, model_name)
def join_for_sort_model(self, query, model_name):
"""
This should apply any joins needed on the given query, to accommodate
requested sorting on a field associated with the given model.
Please override as necessary, but in all cases you should return a
query, either untouched or else with join(s) applied.
"""
return query
def make_pagination_spec(self):
# these params are based on 'vuetable-2'
# https://github.com/ratiw/vuetable-2-tutorial/wiki/prerequisite#sample-api-endpoint
if 'page' in self.request.params and 'per_page' in self.request.params:
page = self.request.params['page']
per_page = self.request.params['per_page']
if page.isdigit() and per_page.isdigit():
return int(page), int(per_page)
# these params are based on 'vue-tables-2'
# https://github.com/matfish2/vue-tables-2#server-side
if 'page' in self.request.params and 'limit' in self.request.params:
page = self.request.params['page']
limit = self.request.params['limit']
if page.isdigit() and limit.isdigit():
return int(page), int(limit)
def base_query(self):
cls = self.get_model_class()
query = self.Session.query(cls)
return query
def get_fieldnames(self):
if not hasattr(self, '_fieldnames'):
self._fieldnames = get_fieldnames(
self.rattail_config, self.model_class,
columns=True, proxies=True, relations=False)
return self._fieldnames
def normalize(self, obj):
data = {'_str': str(obj)}
for field in self.get_fieldnames():
data[field] = getattr(obj, field)
return data
def _collection_get(self):
from sa_filters import apply_filters, apply_sort, apply_pagination
query = self.base_query()
context = {}
# maybe filter query
filter_spec = self.make_filter_spec()
if filter_spec:
query = apply_filters(query, filter_spec)
# maybe sort query
sort_spec = self.make_sort_spec()
if sort_spec:
query = self.join_for_sort_spec(query, sort_spec)
query = apply_sort(query, sort_spec)
# maybe paginate query
pagination_spec = self.make_pagination_spec()
if pagination_spec:
number, size = pagination_spec
query, pagination = apply_pagination(query, page_number=number, page_size=size)
# these properties are based on 'vuetable-2'
# https://www.vuetable.com/guide/pagination.html#how-the-pagination-component-works
context['total'] = pagination.total_results
context['per_page'] = pagination.page_size
context['current_page'] = pagination.page_number
context['last_page'] = pagination.num_pages
context['from'] = pagination.page_size * (pagination.page_number - 1) + 1
to = pagination.page_size * (pagination.page_number - 1) + pagination.page_size
if to > pagination.total_results:
context['to'] = pagination.total_results
else:
context['to'] = to
# these properties are based on 'vue-tables-2'
# https://github.com/matfish2/vue-tables-2#server-side
context['count'] = pagination.total_results
objects = [self.normalize(obj) for obj in query]
# TODO: test this for ratbob!
context[self.get_collection_key()] = objects
# these properties are based on 'vue-tables-2'
# https://github.com/matfish2/vue-tables-2#server-side
context['data'] = objects
if 'count' not in context:
context['count'] = len(objects)
return context
def get_object(self, uuid=None):
if not uuid:
uuid = self.request.matchdict['uuid']
obj = self.Session.get(self.get_model_class(), uuid)
if obj:
return obj
raise self.notfound()
def _get(self, obj=None, uuid=None):
if not obj:
obj = self.get_object(uuid=uuid)
key = self.get_object_key()
normal = self.normalize(obj)
return {key: normal, 'data': normal}
def _collection_post(self):
"""
Default method for actually processing a POST request for the
collection, aka. "create new object".
"""
# assume our data comes only from request JSON body
data = self.request.json_body
# add instance to session, and return data for it
try:
obj = self.create_object(data)
except Exception as error:
return self.json_response({'error': str(error)})
else:
self.Session.flush()
return self._get(obj)
def create_object(self, data):
"""
Create a new object instance and populate it with the given data.
Note that this method by default will only populate *simple* fields, so
you may need to subclass and override to add more complex field logic.
"""
# create new instance of model class
cls = self.get_model_class()
obj = cls()
# "update" new object with given data
obj = self.update_object(obj, data)
# that's all we can do here, subclass must override if more needed
self.Session.add(obj)
return obj
def _post(self, uuid=None):
"""
Default method for actually processing a POST request for an object,
aka. "update existing object".
"""
if not uuid:
uuid = self.request.matchdict['uuid']
obj = self.Session.get(self.get_model_class(), uuid)
if not obj:
raise self.notfound()
# assume our data comes only from request JSON body
data = self.request.json_body
# try to update data for object, returning error as necessary
obj = self.update_object(obj, data)
if isinstance(obj, dict) and 'error' in obj:
return {'error': obj['error']}
# return data for object
self.Session.flush()
return self._get(obj)
def update_object(self, obj, data):
"""
Update the given object instance with the given data.
Note that this method by default will only update *simple* fields, so
you may need to subclass and override to add more complex field logic.
"""
# set values for simple fields only
for key, value in data.items():
if hasattr(obj, key):
# TODO: what about datetime, decimal etc.?
setattr(obj, key, value)
# that's all we can do here, subclass must override if more needed
return obj
##############################
# delete
##############################
def _delete(self):
"""
View to handle DELETE action for an existing record/object.
"""
obj = self.get_object()
self.delete_object(obj)
def delete_object(self, obj):
"""
Delete the object, or mark it as deleted, or whatever you need to do.
"""
# flush immediately to force any pending integrity errors etc.
self.Session.delete(obj)
self.Session.flush()
##############################
# download
##############################
def download(self):
"""
GET view allowing for download of a single file, which is attached to a
given record.
"""
obj = self.get_object()
filename = self.request.GET.get('filename', None)
if not filename:
raise self.notfound()
path = self.download_path(obj, filename)
response = self.file_response(path)
return response
def download_path(self, obj, filename):
"""
Should return absolute path on disk, for the given object and filename.
Result will be used to return a file response to client.
"""
raise NotImplementedError
def rawbytes(self):
"""
GET view allowing for direct access to the raw bytes of a file, which
is attached to a given record. Basically the same as 'download' except
this does not come as an attachment.
"""
obj = self.get_object()
# TODO: is this really needed?
# filename = self.request.GET.get('filename', None)
# if filename:
# path = self.download_path(obj, filename)
# return self.file_response(path, attachment=False)
return self.rawbytes_response(obj)
def rawbytes_response(self, obj):
raise NotImplementedError
##############################
# autocomplete
##############################
def autocomplete(self):
"""
View which accepts a single ``term`` param, and returns a list of
autocomplete results to match.
"""
term = self.request.params.get('term', '').strip()
term = self.prepare_autocomplete_term(term)
if not term:
return []
results = self.get_autocomplete_data(term)
return [{'label': self.autocomplete_display(x),
'value': self.autocomplete_value(x)}
for x in results]
@property
def autocomplete_fieldname(self):
raise NotImplementedError("You must define `autocomplete_fieldname` "
"attribute for API view class: {}".format(
self.__class__))
def autocomplete_display(self, obj):
return getattr(obj, self.autocomplete_fieldname)
def autocomplete_value(self, obj):
return obj.uuid
def get_autocomplete_data(self, term):
query = self.make_autocomplete_query(term)
return query.all()
def make_autocomplete_query(self, term):
model_class = self.get_model_class()
query = self.Session.query(model_class)
query = self.filter_autocomplete_query(query)
field = getattr(model_class, self.autocomplete_fieldname)
query = query.filter(field.ilike('%%%s%%' % term))\
.order_by(field)
return query
def filter_autocomplete_query(self, query):
return query
def prepare_autocomplete_term(self, term):
"""
If necessary, massage the incoming search term for use with the
autocomplete query.
"""
return term
@classmethod
def defaults(cls, config):
cls._defaults(config)
@classmethod
def _defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
collection_url_prefix = cls.get_collection_url_prefix()
object_url_prefix = cls.get_object_url_prefix()
# first, the primary resource API
# list/search
if cls.listable:
cls.establish_method('collection_get')
resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix))
# create
if cls.creatable:
cls.establish_method('collection_post')
if hasattr(cls, 'permission_to_create'):
permission = cls.permission_to_create
else:
permission = '{}.create'.format(permission_prefix)
resource.add_view(cls.collection_post, permission=permission)
# view
if cls.viewable:
cls.establish_method('get')
resource.add_view(cls.get, permission='{}.view'.format(permission_prefix))
# edit
if cls.editable:
cls.establish_method('post')
resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix))
# delete
if cls.deletable:
cls.establish_method('delete')
resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix))
# register primary resource API via cornice
object_resource = resource.add_resource(
cls,
collection_path=collection_url_prefix,
# TODO: probably should allow for other (composite?) key fields
path='{}/{{uuid}}'.format(object_url_prefix))
config.add_cornice_resource(object_resource)
# now for some more "custom" things, which are still somewhat generic
# autocomplete
if cls.supports_autocomplete:
autocomplete = Service(name='{}.autocomplete'.format(route_prefix),
path='{}/autocomplete'.format(collection_url_prefix))
autocomplete.add_view('GET', 'autocomplete', klass=cls,
permission='{}.list'.format(permission_prefix))
config.add_cornice_service(autocomplete)
# download
if cls.supports_download:
download = Service(name='{}.download'.format(route_prefix),
# TODO: probably should allow for other (composite?) key fields
path='{}/{{uuid}}/download'.format(object_url_prefix))
download.add_view('GET', 'download', klass=cls,
permission='{}.download'.format(permission_prefix))
config.add_cornice_service(download)
# rawbytes
if cls.supports_rawbytes:
rawbytes = Service(name='{}.rawbytes'.format(route_prefix),
# TODO: probably should allow for other (composite?) key fields
path='{}/{{uuid}}/rawbytes'.format(object_url_prefix))
rawbytes.add_view('GET', 'rawbytes', klass=cls,
permission='{}.download'.format(permission_prefix))
config.add_cornice_service(rawbytes)

View file

@ -1,43 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Master View (v2)
"""
from __future__ import unicode_literals, absolute_import
import warnings
from tailbone.api import APIMasterView
class APIMasterView2(APIMasterView):
"""
Base class for data model REST API views.
"""
def __init__(self, request, context=None):
warnings.warn("APIMasterView2 class is deprecated; please use "
"APIMasterView instead",
DeprecationWarning, stacklevel=2)
super(APIMasterView2, self).__init__(request, context=context)

View file

@ -1,59 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Person Views
"""
from rattail.db import model
from tailbone.api import APIMasterView
class PersonView(APIMasterView):
"""
API views for Person data
"""
model_class = model.Person
permission_prefix = 'people'
collection_url_prefix = '/people'
object_url_prefix = '/person'
def normalize(self, person):
return {
'uuid': person.uuid,
'_str': str(person),
'first_name': person.first_name,
'last_name': person.last_name,
'display_name': person.display_name,
}
def defaults(config, **kwargs):
base = globals()
PersonView = kwargs.get('PersonView', base['PersonView'])
PersonView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,220 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Product Views
"""
import logging
import sqlalchemy as sa
from sqlalchemy import orm
from cornice import Service
from rattail.db import model
from tailbone.api import APIMasterView
log = logging.getLogger(__name__)
class ProductView(APIMasterView):
"""
API views for Product data
"""
model_class = model.Product
collection_url_prefix = '/products'
object_url_prefix = '/product'
supports_autocomplete = True
def __init__(self, request, context=None):
super(ProductView, self).__init__(request, context=context)
app = self.get_rattail_app()
self.products_handler = app.get_products_handler()
def normalize(self, product):
# get what we can from handler
data = self.products_handler.normalize_product(product, fields=[
'brand_name',
'full_description',
'department_name',
'unit_price_display',
'sale_price',
'sale_price_display',
'sale_ends',
'sale_ends_display',
'tpr_price',
'tpr_price_display',
'tpr_ends',
'tpr_ends_display',
'current_price',
'current_price_display',
'current_ends',
'current_ends_display',
'vendor_name',
'costs',
'image_url',
])
# but must supplement
cost = product.cost
data.update({
'upc': str(product.upc),
'scancode': product.scancode,
'item_id': product.item_id,
'item_type': product.item_type,
'status_code': product.status_code,
'default_unit_cost': cost.unit_cost if cost else None,
'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None,
})
return data
def make_autocomplete_query(self, term):
query = self.Session.query(model.Product)\
.outerjoin(model.Brand)\
.filter(sa.or_(
model.Brand.name.ilike('%{}%'.format(term)),
model.Product.description.ilike('%{}%'.format(term))))
if not self.request.has_perm('products.view_deleted'):
query = query.filter(model.Product.deleted == False)
query = query.order_by(model.Brand.name,
model.Product.description)\
.options(orm.joinedload(model.Product.brand))
return query
def autocomplete_display(self, product):
return product.full_description
def quick_lookup(self):
"""
View for handling "quick lookup" user input, for index page.
"""
data = self.request.GET
entry = data['entry']
product = self.products_handler.locate_product_for_entry(self.Session(),
entry)
if not product:
return {'error': "Product not found"}
return {'ok': True,
'product': self.normalize(product)}
def label_profiles(self):
"""
Returns the set of label profiles available for use with
printing label for product.
"""
app = self.get_rattail_app()
label_handler = app.get_label_handler()
model = self.model
profiles = []
for profile in label_handler.get_label_profiles(self.Session()):
profiles.append({
'uuid': profile.uuid,
'description': profile.description,
})
return {'label_profiles': profiles}
def print_labels(self):
app = self.get_rattail_app()
label_handler = app.get_label_handler()
model = self.model
data = self.request.json_body
uuid = data.get('label_profile_uuid')
profile = self.Session.get(model.LabelProfile, uuid) if uuid else None
if not profile:
return {'error': "Label profile not found"}
uuid = data.get('product_uuid')
product = self.Session.get(model.Product, uuid) if uuid else None
if not product:
return {'error': "Product not found"}
try:
quantity = int(data.get('quantity'))
except:
return {'error': "Quantity must be integer"}
printer = label_handler.get_printer(profile)
if not printer:
return {'error': "Couldn't get printer from label profile"}
try:
printer.print_labels([({'product': product}, quantity)])
except Exception as error:
log.warning("error occurred while printing labels", exc_info=True)
return {'error': str(error)}
return {'ok': True}
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._product_defaults(config)
@classmethod
def _product_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
collection_url_prefix = cls.get_collection_url_prefix()
# quick lookup
quick_lookup = Service(name='{}.quick_lookup'.format(route_prefix),
path='{}/quick-lookup'.format(collection_url_prefix))
quick_lookup.add_view('GET', 'quick_lookup', klass=cls,
permission='{}.list'.format(permission_prefix))
config.add_cornice_service(quick_lookup)
# label profiles
label_profiles = Service(name=f'{route_prefix}.label_profiles',
path=f'{collection_url_prefix}/label-profiles')
label_profiles.add_view('GET', 'label_profiles', klass=cls,
permission=f'{permission_prefix}.print_labels')
config.add_cornice_service(label_profiles)
# print labels
print_labels = Service(name='{}.print_labels'.format(route_prefix),
path='{}/print-labels'.format(collection_url_prefix))
print_labels.add_view('POST', 'print_labels', klass=cls,
permission='{}.print_labels'.format(permission_prefix))
config.add_cornice_service(print_labels)
def defaults(config, **kwargs):
base = globals()
ProductView = kwargs.get('ProductView', base['ProductView'])
ProductView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,64 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Upgrade Views
"""
from rattail.db import model
from tailbone.api import APIMasterView
class UpgradeView(APIMasterView):
"""
REST API views for Upgrade model.
"""
model_class = model.Upgrade
collection_url_prefix = '/upgrades'
object_url_prefix = '/upgrades'
def normalize(self, upgrade):
data = {
'created': upgrade.created.isoformat(),
'description': upgrade.description,
'enabled': upgrade.enabled,
'executed': upgrade.executed.isoformat() if upgrade.executed else None,
# 'executed_by':
}
if upgrade.status_code is None:
data['status_code'] = None
else:
data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
str(upgrade.status_code))
return data
def defaults(config, **kwargs):
base = globals()
UpgradeView = kwargs.get('UpgradeView', base['UpgradeView'])
UpgradeView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,71 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - User Views
"""
from rattail.db import model
from tailbone.api import APIMasterView
class UserView(APIMasterView):
"""
API views for User data
"""
model_class = model.User
collection_url_prefix = '/users'
object_url_prefix = '/user'
def normalize(self, user):
return {
'uuid': user.uuid,
'username': user.username,
'person_display_name': (user.person.display_name or '') if user.person else '',
'active': user.active,
}
def interpret_sortcol(self, order_by):
if order_by == 'person_display_name':
return self.sortcol('Person', 'display_name')
return self.sortcol(order_by)
def join_for_sort_model(self, query, model_name):
if model_name == 'Person':
query = query.outerjoin(model.Person)
return query
def update_object(self, user, data):
# TODO: should ensure prevent_password_change is respected
return super(UserView, self).update_object(user, data)
def defaults(config, **kwargs):
base = globals()
UserView = kwargs.get('UserView', base['UserView'])
UserView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,57 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Vendor Views
"""
from rattail.db import model
from tailbone.api import APIMasterView
class VendorView(APIMasterView):
model_class = model.Vendor
collection_url_prefix = '/vendors'
object_url_prefix = '/vendor'
supports_autocomplete = True
autocomplete_fieldname = 'name'
def normalize(self, vendor):
return {
'uuid': vendor.uuid,
'_str': str(vendor),
'id': vendor.id,
'name': vendor.name,
}
def defaults(config, **kwargs):
base = globals()
VendorView = kwargs.get('VendorView', base['VendorView'])
VendorView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,234 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Work Order Views
"""
import datetime
from rattail.db.model import WorkOrder
from cornice import Service
from tailbone.api import APIMasterView
class WorkOrderView(APIMasterView):
model_class = WorkOrder
collection_url_prefix = '/workorders'
object_url_prefix = '/workorder'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
app = self.get_rattail_app()
self.workorder_handler = app.get_workorder_handler()
def normalize(self, workorder):
data = super().normalize(workorder)
data.update({
'customer_name': workorder.customer.name,
'status_label': self.enum.WORKORDER_STATUS[workorder.status_code],
'date_submitted': str(workorder.date_submitted or ''),
'date_received': str(workorder.date_received or ''),
'date_released': str(workorder.date_released or ''),
'date_delivered': str(workorder.date_delivered or ''),
})
return data
def create_object(self, data):
# invoke the handler instead of normal API CRUD logic
workorder = self.workorder_handler.make_workorder(self.Session(), **data)
return workorder
def update_object(self, workorder, data):
date_fields = [
'date_submitted',
'date_received',
'date_released',
'date_delivered',
]
# coerce date field values to proper datetime.date objects
for field in date_fields:
if field in data:
if data[field] == '':
data[field] = None
elif not isinstance(data[field], datetime.date):
date = datetime.datetime.strptime(data[field], '%Y-%m-%d').date()
data[field] = date
# coerce status code value to proper integer
if 'status_code' in data:
data['status_code'] = int(data['status_code'])
return super().update_object(workorder, data)
def status_codes(self):
"""
Retrieve all info about possible work order status codes.
"""
return self.workorder_handler.status_codes()
def receive(self):
"""
Sets work order status to "received".
"""
workorder = self.get_object()
self.workorder_handler.receive(workorder)
self.Session.flush()
return self.normalize(workorder)
def await_estimate(self):
"""
Sets work order status to "awaiting estimate confirmation".
"""
workorder = self.get_object()
self.workorder_handler.await_estimate(workorder)
self.Session.flush()
return self.normalize(workorder)
def await_parts(self):
"""
Sets work order status to "awaiting parts".
"""
workorder = self.get_object()
self.workorder_handler.await_parts(workorder)
self.Session.flush()
return self.normalize(workorder)
def work_on_it(self):
"""
Sets work order status to "working on it".
"""
workorder = self.get_object()
self.workorder_handler.work_on_it(workorder)
self.Session.flush()
return self.normalize(workorder)
def release(self):
"""
Sets work order status to "released".
"""
workorder = self.get_object()
self.workorder_handler.release(workorder)
self.Session.flush()
return self.normalize(workorder)
def deliver(self):
"""
Sets work order status to "delivered".
"""
workorder = self.get_object()
self.workorder_handler.deliver(workorder)
self.Session.flush()
return self.normalize(workorder)
def cancel(self):
"""
Sets work order status to "canceled".
"""
workorder = self.get_object()
self.workorder_handler.cancel(workorder)
self.Session.flush()
return self.normalize(workorder)
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._workorder_defaults(config)
@classmethod
def _workorder_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
collection_url_prefix = cls.get_collection_url_prefix()
object_url_prefix = cls.get_object_url_prefix()
# status codes
status_codes = Service(name='{}.status_codes'.format(route_prefix),
path='{}/status-codes'.format(collection_url_prefix))
status_codes.add_view('GET', 'status_codes', klass=cls,
permission='{}.list'.format(permission_prefix))
config.add_cornice_service(status_codes)
# receive
receive = Service(name='{}.receive'.format(route_prefix),
path='{}/{{uuid}}/receive'.format(object_url_prefix))
receive.add_view('POST', 'receive', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(receive)
# await estimate confirmation
await_estimate = Service(name='{}.await_estimate'.format(route_prefix),
path='{}/{{uuid}}/await-estimate'.format(object_url_prefix))
await_estimate.add_view('POST', 'await_estimate', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(await_estimate)
# await parts
await_parts = Service(name='{}.await_parts'.format(route_prefix),
path='{}/{{uuid}}/await-parts'.format(object_url_prefix))
await_parts.add_view('POST', 'await_parts', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(await_parts)
# work on it
work_on_it = Service(name='{}.work_on_it'.format(route_prefix),
path='{}/{{uuid}}/work-on-it'.format(object_url_prefix))
work_on_it.add_view('POST', 'work_on_it', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(work_on_it)
# release
release = Service(name='{}.release'.format(route_prefix),
path='{}/{{uuid}}/release'.format(object_url_prefix))
release.add_view('POST', 'release', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(release)
# deliver
deliver = Service(name='{}.deliver'.format(route_prefix),
path='{}/{{uuid}}/deliver'.format(object_url_prefix))
deliver.add_view('POST', 'deliver', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(deliver)
# cancel
cancel = Service(name='{}.cancel'.format(route_prefix),
path='{}/{{uuid}}/cancel'.format(object_url_prefix))
cancel.add_view('POST', 'cancel', klass=cls,
permission='{}.edit'.format(permission_prefix))
config.add_cornice_service(cancel)
def defaults(config, **kwargs):
base = globals()
WorkOrderView = kwargs.get('WorkOrderView', base['WorkOrderView'])
WorkOrderView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -1,46 +1,51 @@
# -*- coding: utf-8; -*-
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
# terms of the GNU 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 General Public License for more
# details.
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Application Entry Point
"""
from __future__ import unicode_literals, absolute_import
import os
import warnings
from sqlalchemy.orm import sessionmaker, scoped_session
from wuttjamaican.util import parse_list
import sqlalchemy as sa
import rattail.db
from rattail.config import make_config
from rattail.exceptions import ConfigurationError
from rattail.db.util import get_engines
from rattail.db.continuum import configure_versioning
from rattail.db.types import GPCType
import formalchemy as fa
from pyramid.config import Configurator
from zope.sqlalchemy import register
from pyramid.authentication import SessionAuthenticationPolicy
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
from tailbone.auth import TailboneAuthorizationPolicy
from tailbone.forms import renderers
from tailbone.forms.alchemy import TemplateEngine
def make_rattail_config(settings):
@ -54,37 +59,43 @@ def make_rattail_config(settings):
# 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.")
path = settings.get('edbob.config')
if not path or not os.path.exists(path):
raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config "
"to the path of your config file. Lame, but necessary.")
warnings.warn("[app:main] setting 'edbob.config' is deprecated; "
"please use 'rattail.config' setting instead",
DeprecationWarning)
rattail_config = make_config(path)
settings['rattail_config'] = rattail_config
rattail_config.configure_logging()
# nb. this is for compaibility with wuttaweb
settings['wutta_config'] = rattail_config
rattail_engines = settings.get('rattail_engines')
if not rattail_engines:
# 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)
# Load all Rattail database engines from config, and store in settings
# dict. This is necessary e.g. in the case of a host server, to have
# access to its subordinate store servers.
rattail_engines = get_engines(rattail_config)
settings['rattail_engines'] = rattail_engines
# Configure the database session classes. Note that most of the time we'll
# be using the Tailbone Session, but occasionally (e.g. within batch
# processing threads) we want the Rattail Session. The reason is that
# during normal request processing, the Tailbone Session is preferable as
# it includes Zope Transaction magic. Within an explicitly-spawned thread
# however, this is *not* desirable.
rattail.db.Session.configure(bind=rattail_engines['default'])
tailbone.db.Session.configure(bind=rattail_engines['default'])
if hasattr(rattail_config, 'tempmon_engine'):
tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine)
# maybe set "future" behavior for SQLAlchemy
if rattail_config.getbool('rattail.db', 'sqlalchemy_future_mode', usedb=False):
tailbone.db.Session.configure(future=True)
# create session wrappers for each "extra" Trainwreck engine
for key, engine in rattail_config.trainwreck_engines.items():
if key != 'default':
Session = scoped_session(sessionmaker(bind=engine))
register(Session)
tailbone.db.ExtraTrainwreckSessions[key] = Session
# Make sure rattail config object uses our scoped session, to avoid
# unnecessary connections (and pooling limits).
rattail_config._session_factory = lambda: (tailbone.db.Session(), False)
# Configure (or not) Continuum versioning.
configure_versioning(rattail_config)
return rattail_config
@ -94,12 +105,7 @@ def provide_postgresql_settings(settings):
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)
settings.setdefault('tm.attempts', 2)
class Root(dict):
@ -113,201 +119,47 @@ class Root(dict):
self.request = request
def make_pyramid_config(settings, configure_csrf=True):
def make_pyramid_config(settings):
"""
Make a Pyramid config object from the given settings.
"""
rattail_config = settings['rattail_config']
config = Configurator(settings=settings, root_factory=Root)
config = settings.pop('pyramid_config', None)
if config:
config.set_root_factory(Root)
else:
# Configure user authentication / authorization.
config.set_authentication_policy(SessionAuthenticationPolicy())
config.set_authorization_policy(TailboneAuthorizationPolicy())
# 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))
# always require CSRF token protection
config.set_default_csrf_options(require_csrf=True, token='_csrf')
# 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)
# Add some permissions magic.
config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
# 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')
# TODO: This can finally be removed once all CRUD/index views have been
# converted to use the new master view etc.
for label, perms in settings.get('edbob.permissions', []):
groupkey = label.lower().replace(' ', '_')
config.add_tailbone_permission_group(groupkey, label)
for key, label in perms:
config.add_tailbone_permission(groupkey, key, label)
# 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')
# Configure FormAlchemy.
fa.config.engine = TemplateEngine()
fa.FieldSet.default_renderers[sa.Boolean] = renderers.YesNoFieldRenderer
fa.FieldSet.default_renderers[sa.Date] = renderers.DateFieldRenderer
fa.FieldSet.default_renderers[sa.DateTime] = renderers.DateTimeFieldRenderer
fa.FieldSet.default_renderers[sa.Time] = renderers.TimeFieldRenderer
fa.FieldSet.default_renderers[GPCType] = renderers.GPCFieldRenderer
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,
@ -321,8 +173,7 @@ def main(global_config, **settings):
"""
This function returns a Pyramid WSGI application.
"""
settings.setdefault('mako.directories', ['tailbone:templates',
'wuttaweb:templates'])
settings.setdefault('mako.directories', ['tailbone:templates'])
rattail_config = make_rattail_config(settings)
pyramid_config = make_pyramid_config(settings)
pyramid_config.include('tailbone')

View file

@ -1,110 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
ASGI App Utilities
"""
import os
import configparser
import logging
from rattail.util import load_object
from asgiref.wsgi import WsgiToAsgi
log = logging.getLogger(__name__)
class TailboneWsgiToAsgi(WsgiToAsgi):
"""
Custom WSGI -> ASGI wrapper, to add routing for websockets.
"""
async def __call__(self, scope, *args, **kwargs):
protocol = scope['type']
path = scope['path']
# strip off the root path, if non-empty. needed for serving
# under /poser or anything other than true site root
root_path = scope['root_path']
if root_path and path.startswith(root_path):
path = path[len(root_path):]
if protocol == 'websocket':
websockets = self.wsgi_application.registry.get(
'tailbone_websockets', {})
if path in websockets:
await websockets[path](scope, *args, **kwargs)
try:
await super().__call__(scope, *args, **kwargs)
except ValueError as e:
# The developer may wish to improve handling of this exception.
# See https://github.com/Pylons/pyramid_cookbook/issues/225 and
# https://asgi.readthedocs.io/en/latest/specs/www.html#websocket
pass
except Exception as e:
raise e
def make_asgi_app(main_app=None):
"""
This function returns an ASGI application.
"""
path = os.environ.get('TAILBONE_ASGI_CONFIG')
if not path:
raise RuntimeError("You must define TAILBONE_ASGI_CONFIG env variable.")
# make a config parser good enough to load pyramid settings
configdir = os.path.dirname(path)
parser = configparser.ConfigParser(defaults={'__file__': path,
'here': configdir})
# read the config file
parser.read(path)
# parse the settings needed for pyramid app
settings = dict(parser.items('app:main'))
if isinstance(main_app, str):
make_wsgi_app = load_object(main_app)
elif callable(main_app):
make_wsgi_app = main_app
else:
if main_app:
log.warning("specified main app of unknown type: %s", main_app)
make_wsgi_app = load_object('tailbone.app:main')
# construct a pyramid app "per usual"
app = make_wsgi_app({}, **settings)
# then wrap it with ASGI
return TailboneWsgiToAsgi(app)
def asgi_main():
"""
This function returns an ASGI application.
"""
return make_asgi_app()

View file

@ -1,88 +1,85 @@
# -*- coding: utf-8; -*-
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
# terms of the GNU 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 General Public License for more
# details.
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Authentication & Authorization
"""
from __future__ import unicode_literals, absolute_import
import logging
import re
from wuttjamaican.util import UNSPECIFIED
from rattail.db import model
from rattail.db.auth import has_permission
from rattail.util import prettify, NOTSET
from pyramid.security import remember, forget
from zope.interface import implementer
from pyramid.interfaces import IAuthorizationPolicy
from pyramid.security import remember, Everyone, Authenticated
from wuttaweb.auth import WuttaSecurityPolicy
from tailbone.db import Session
log = logging.getLogger(__name__)
def login_user(request, user, timeout=UNSPECIFIED):
def login_user(request, user, type_='default', timeout=NOTSET):
"""
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)
if timeout is NOTSET:
timeout = get_session_timeout_for_user(request.rattail_config, user, type_) or None
log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
set_session_timeout(request, timeout)
return headers
def logout_user(request):
def get_session_timeout_for_user(config, user, type_='default'):
"""
Perform the logout action for the given request. Note that this returns a
``headers`` dict which you should pass to the redirect.
Must return a value to be used to set the session timeout for the given
user. By default this will return ``None`` if the user has the
"forever session" permission, otherwise will try to read a default
value from config:
.. code-block:: ini
[tailbone]
# set session timeout to 10 minutes:
session.timeout.default = 600
# or, set to 0 to disable:
#session.timeout.default = 0
"""
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
if not has_permission(Session(), user, 'general.forever_session'):
timeout = config.getint('tailbone', 'session.timeout.{}'.format(type_))
# TODO: remove this hack after no longer needed
if timeout is None and type_ == 'default':
timeout = config.getint('tailbone', 'session.default_timeout')
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)
return timeout if timeout is not None else 300 # 5 minutes
def set_session_timeout(request, timeout):
@ -92,42 +89,50 @@ def set_session_timeout(request, timeout):
request.session['_timeout'] = timeout or None
class TailboneSecurityPolicy(WuttaSecurityPolicy):
@implementer(IAuthorizationPolicy)
class TailboneAuthorizationPolicy(object):
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 permits(self, context, principals, permission):
for userid in principals:
if userid not in (Everyone, Authenticated):
if context.request.user and context.request.user.uuid == userid:
return context.request.has_perm(permission)
else:
assert False # should no longer happen..right?
user = Session.query(model.User).get(userid)
if user:
if has_permission(Session(), user, permission):
return True
if Everyone in principals:
return has_permission(Session(), None, permission)
return False
def load_identity(self, request):
config = request.registry.settings.get('rattail_config')
app = config.get_app()
user = None
def principals_allowed_by_permission(self, context, permission):
raise NotImplementedError
if self.api_mode:
# determine/load user from header token if present
credentials = request.headers.get('Authorization')
if credentials:
match = re.match(r'^Bearer (\S+)$', credentials)
if match:
token = match.group(1)
auth = app.get_auth_handler()
user = auth.authenticate_user_token(self.db_session, token)
def add_permission_group(config, key, label=None, overwrite=True):
"""
Add a permission group to the app configuration.
"""
def action():
perms = config.get_settings().get('tailbone_permissions', {})
if key not in perms or overwrite:
group = perms.setdefault(key, {'key': key})
group['label'] = label or prettify(key)
config.add_settings({'tailbone_permissions': perms})
config.action(None, action)
if not user:
# fetch user uuid from current session
uuid = self.session_helper.authenticated_userid(request)
if not uuid:
return
# 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
def add_permission(config, groupkey, key, label=None):
"""
Add a permission to the app configuration.
"""
def action():
perms = config.get_settings().get('tailbone_permissions', {})
group = perms.setdefault(groupkey, {'key': groupkey})
group.setdefault('label', prettify(groupkey))
perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
perm['label'] = label or prettify(key)
config.add_settings({'tailbone_permissions': perms})
config.action(None, action)

View file

@ -1,23 +1,23 @@
# -*- coding: utf-8; -*-
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
# terms of the GNU 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 General Public License for more
# details.
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
@ -27,12 +27,10 @@ Note that most of the code for this module was copied from the beaker and
pyramid_beaker projects.
"""
from __future__ import unicode_literals, absolute_import
import time
from pkg_resources import parse_version
from rattail.util import get_pkg_version
import beaker
from beaker.session import Session
from beaker.util import coerce_session_params
from pyramid.settings import asbool
@ -47,10 +45,6 @@ class TailboneSession(Session):
def load(self):
"Loads the data from this session from persistent storage"
# are we using older version of beaker?
old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12')
self.namespace = self.namespace_class(self.id,
data_dir=self.data_dir,
digest_filenames=False,
@ -66,12 +60,8 @@ class TailboneSession(Session):
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)
if (session_data is not None and self.encrypt_key):
session_data = self._decrypt_data(session_data)
# Memcached always returns a key, its None when its not
# present
@ -100,7 +90,6 @@ class TailboneSession(Session):
# for this module entirely...
timeout = session_data.get('_timeout', self.timeout)
if timeout is not None and \
'_accessed_time' in session_data and \
now - session_data['_accessed_time'] > timeout:
timed_out = True
else:
@ -114,6 +103,9 @@ class TailboneSession(Session):
# Update the current _accessed_time
session_data['_accessed_time'] = now
# Set the path if applicable
if '_path' in session_data:
self._path = session_data['_path']
self.update(session_data)
self.accessed_dict = session_data.copy()
finally:

View file

@ -1,80 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Cleanup logic
"""
from __future__ import unicode_literals, absolute_import
import os
import logging
import time
from rattail.cleanup import Cleaner
log = logging.getLogger(__name__)
class BeakerCleaner(Cleaner):
"""
Cleanup logic for old Beaker session files.
"""
def get_session_dir(self):
session_dir = self.config.get('rattail.cleanup', 'beaker.session_dir')
if session_dir and os.path.isdir(session_dir):
return session_dir
session_dir = os.path.join(self.config.appdir(), 'sessions')
if os.path.isdir(session_dir):
return session_dir
def cleanup(self, session, dry_run=False, progress=None, **kwargs):
session_dir = self.get_session_dir()
if not session_dir:
return
data_dir = os.path.join(session_dir, 'data')
lock_dir = os.path.join(session_dir, 'lock')
# looking for files older than X days
days = self.config.getint('rattail.cleanup',
'beaker.session_cutoff_days',
default=30)
cutoff = time.time() - 3600 * 24 * days
for topdir in (data_dir, lock_dir):
if not os.path.isdir(topdir):
continue
for dirpath, dirnames, filenames in os.walk(topdir):
for fname in filenames:
path = os.path.join(dirpath, fname)
ts = os.path.getmtime(path)
if ts <= cutoff:
if dry_run:
log.debug("would delete file: %s", path)
else:
os.remove(path)
log.debug("deleted file: %s", path)

View file

@ -1,39 +1,38 @@
# -*- coding: utf-8; -*-
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
# terms of the GNU 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 General Public License for more
# details.
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Rattail config extension for Tailbone
"""
import warnings
from wuttjamaican.conf import WuttaConfigExtension
from __future__ import unicode_literals, absolute_import
from rattail.config import ConfigExtension as BaseExtension
from rattail.db.config import configure_session
from tailbone.db import Session
class ConfigExtension(WuttaConfigExtension):
class ConfigExtension(BaseExtension):
"""
Rattail config extension for Tailbone. Does the following:
@ -48,31 +47,3 @@ class ConfigExtension(WuttaConfigExtension):
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)

View file

@ -1,29 +1,31 @@
# -*- coding: utf-8; -*-
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
# terms of the GNU 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 General Public License for more
# details.
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Database sessions etc.
Database Stuff
"""
from __future__ import unicode_literals, absolute_import
import sqlalchemy as sa
from zope.sqlalchemy import datamanager
import sqlalchemy_continuum as continuum
@ -33,37 +35,23 @@ 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))
Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, rattail_record_changes=False))
# not necessarily used, but here if you need it
TempmonSession = scoped_session(sessionmaker())
TrainwreckSession = scoped_session(sessionmaker())
# empty dict for now, this must populated on app startup (if needed)
ExtraTrainwreckSessions = {}
class TailboneSessionDataManager(datamanager.SessionDataManager):
"""
Integrate a top level sqlalchemy session transaction into a zope
transaction
"""Integrate a top level sqlalchemy session transaction into a zope transaction
One phase variant.
.. note::
This class appears to be necessary in order for the
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.
This class appears to be necessary in order for the Continuum
integration to work alongside the Zope transaction integration.
"""
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
@ -75,42 +63,25 @@ class TailboneSessionDataManager(datamanager.SessionDataManager):
self._finish('committed')
def join_transaction(
session,
initial_state=datamanager.STATUS_ACTIVE,
transaction_manager=datamanager.zope_transaction.manager,
keep_session=False,
):
"""
Join a session to a transaction using the appropriate datamanager.
def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
"""Join a session to a transaction using the appropriate datamanager.
It is safe to call this multiple times, if the session is already
joined then it just returns.
It is safe to call this multiple times, if the session is already joined
then it just returns.
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or
STATUS_READONLY
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY
If using the default initial status of STATUS_ACTIVE, you must
ensure that mark_changed(session) is called when data is written
to the database.
If using the default initial status of STATUS_ACTIVE, you must ensure that
mark_changed(session) is called when data is written to the database.
The ZopeTransactionExtesion SessionExtension can be used to ensure
that this is called automatically after session write operations.
The ZopeTransactionExtesion SessionExtension can be used to ensure that this is
called automatically after session write operations.
.. note::
This function 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.
This function is copied from upstream, and tweaked so that our custom
:class:`TailboneSessionDataManager` will be used.
"""
# 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 datamanager._SESSION_STATE.get(id(session), None) is None:
if session.twophase:
DataManager = datamanager.TwoPhaseSessionDataManager
else:
@ -118,74 +89,49 @@ def join_transaction(
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.
class ZopeTransactionExtension(datamanager.ZopeTransactionExtension):
"""Record that a flush has occurred on a session's connection. This allows
the DataManager to rollback rather than commit on read only transactions.
.. 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.
This class is copied from upstream, and tweaked so that our custom
:func:`join_transaction()` will be used.
"""
def after_begin(self, session, transaction, connection):
""" """
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session)
def after_attach(self, session, instance):
""" """
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def join_transaction(self, session):
""" """
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session)
def register(
session,
initial_state=datamanager.STATUS_ACTIVE,
transaction_manager=datamanager.zope_transaction.manager,
keep_session=False,
):
"""
Register ZopeTransaction listener events on the given Session or
Session factory/class.
def register(session, initial_state=datamanager.STATUS_ACTIVE,
transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
"""Register ZopeTransaction listener events on the
given Session or Session factory/class.
This function requires at least SQLAlchemy 0.7 and makes use of
the newer sqlalchemy.event package in order to register event
listeners on the given Session.
This function requires at least SQLAlchemy 0.7 and makes use
of the newer sqlalchemy.event package in order to register event listeners
on the given Session.
The session argument here may be a Session class or subclass, a
sessionmaker or scoped_session instance, or a specific Session
instance. Event listening will be specific to the scope of the
type of argument passed, including specificity to its subclass as
well as its identity.
sessionmaker or scoped_session instance, or a specific Session instance.
Event listening will be specific to the scope of the type of argument
passed, including specificity to its subclass as well as its identity.
.. note::
This function 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.
This function is copied from upstream, and tweaked so that our custom
:class:`ZopeTransactionExtension` will be used.
"""
from sqlalchemy import __version__
assert tuple(int(x) for x in __version__.split(".")) >= (0, 7), \
"SQLAlchemy version 0.7 or greater required to use register()"
from sqlalchemy import event
ext = ZopeTransactionEvents(
initial_state=initial_state,
ext = ZopeTransactionExtension(
initial_state=initial_state,
transaction_manager=transaction_manager,
keep_session=keep_session,
)
@ -197,10 +143,11 @@ def register(
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)
# TODO: We can probably assume a new SA version since we use Continuum now.
if tuple(int(x) for x in sa.__version__.split('.')) >= (0, 7):
register(Session)
register(TempmonSession)
else:
Session.configure(extension=ZopeTransactionExtension())
TempmonSession.configure(extension=ZopeTransactionExtension())

View file

@ -1,291 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tools for displaying data diffs
"""
import sqlalchemy as sa
import sqlalchemy_continuum as continuum
from pyramid.renderers import render
from webhelpers2.html import HTML
class Diff(object):
"""
Core diff class. In sore need of documentation.
You must provide the old and new data sets, and the set of
relevant fields as well, if they cannot be easily introspected.
:param old_data: Dict of "old" data values.
:param new_data: Dict of "old" data values.
:param fields: Sequence of relevant field names. Note that
both data dicts are expected to have keys which match these
field names. If you do not specify the fields then they
will (hopefully) be introspected from the old or new data
sets; however this will not work if they are both empty.
:param monospace: If true, this flag will cause the value
columns to be rendered in monospace font. This is assumed
to be helpful when comparing "raw" data values which are
shown as e.g. ``repr(val)``.
:param enums: Optional dict of enums for use when displaying field
values. If specified, keys should be field names and values
should be enum dicts.
"""
def __init__(self, old_data, new_data, columns=None, fields=None, enums=None,
render_field=None, render_value=None, nature='dirty',
monospace=False, extra_row_attrs=None):
self.old_data = old_data
self.new_data = new_data
self.columns = columns or ["field name", "old value", "new value"]
self.fields = fields or self.make_fields()
self.enums = enums or {}
self._render_field = render_field or self.render_field_default
self.render_value = render_value or self.render_value_default
self.nature = nature
self.monospace = monospace
self.extra_row_attrs = extra_row_attrs
def make_fields(self):
return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower())
def old_value(self, field):
return self.old_data.get(field)
def new_value(self, field):
return self.new_data.get(field)
def values_differ(self, field):
return self.new_value(field) != self.old_value(field)
def render_html(self, template='/diff.mako', **kwargs):
context = kwargs
context['diff'] = self
return HTML.literal(render(template, context))
def get_row_attrs(self, field):
"""
Returns a *rendered* set of extra attributes for the ``<tr>`` element
for the given field. May be an empty string, or a snippet of HTML
attribute syntax, e.g.:
.. code-block:: none
class="diff" foo="bar"
If you wish to supply additional attributes, please define
:attr:`extra_row_attrs`, which can be either a static dict, or a
callable returning a dict.
"""
attrs = {}
if self.values_differ(field):
attrs['class'] = 'diff'
if self.extra_row_attrs:
if callable(self.extra_row_attrs):
attrs.update(self.extra_row_attrs(field, attrs))
else:
attrs.update(self.extra_row_attrs)
return HTML.render_attrs(attrs)
def render_field(self, field):
return self._render_field(field, self)
def render_field_default(self, field, diff):
return field
def render_value_default(self, field, value):
return repr(value)
def render_old_value(self, field):
value = self.old_value(field)
return self.render_value(field, value)
def render_new_value(self, field):
value = self.new_value(field)
return self.render_value(field, value)
class VersionDiff(Diff):
"""
Special diff class, for use with version history views. Note that
while based on :class:`Diff`, this class uses a different
signature for the constructor.
:param version: Reference to a Continuum version record (object).
:param \*args: Typical usage will not require positional args
beyond the ``version`` param, in which case ``old_data`` and
``new_data`` params will be auto-determined based on the
``version``. But if you specify positional args then nothing
automatic is done, they are passed as-is to the parent
:class:`Diff` constructor.
:param \*\*kwargs: Remaining kwargs are passed as-is to the
:class:`Diff` constructor.
"""
def __init__(self, version, *args, **kwargs):
self.version = version
self.mapper = sa.inspect(continuum.parent_class(type(self.version)))
self.version_mapper = sa.inspect(type(self.version))
self.title = kwargs.pop('title', None)
if 'nature' not in kwargs:
if version.previous and version.operation_type == continuum.Operation.DELETE:
kwargs['nature'] = 'deleted'
elif version.previous:
kwargs['nature'] = 'dirty'
else:
kwargs['nature'] = 'new'
if 'fields' not in kwargs:
kwargs['fields'] = self.get_default_fields()
if not args:
old_data = {}
new_data = {}
for field in kwargs['fields']:
if version.previous:
old_data[field] = getattr(version.previous, field)
new_data[field] = getattr(version, field)
args = (old_data, new_data)
super().__init__(*args, **kwargs)
def get_default_fields(self):
fields = sorted(self.version_mapper.columns.keys())
unwanted = [
'transaction_id',
'end_transaction_id',
'operation_type',
]
return [field for field in fields
if field not in unwanted]
def render_version_value(self, field, value, version):
"""
Render the cell value text for the given version/field info.
Note that this method is used to render both sides of the diff
(before and after values).
:param field: Name of the field, as string.
:param value: Raw value for the field, as obtained from ``version``.
:param version: Reference to the Continuum version object.
:returns: Rendered text as string, or ``None``.
"""
text = HTML.tag('span', c=[repr(value)],
style='font-family: monospace;')
# assume the enum display is all we need, if enum exists for the field
if field in self.enums:
# but skip the enum display if None
display = self.enums[field].get(value)
if display is None and value is None:
return text
# otherwise show enum display to the right of raw value
display = self.enums[field].get(value, str(value))
return HTML.tag('span', c=[
text,
HTML.tag('span', c=[display],
style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
])
# next we look for a relationship and may render the foreign object
for prop in self.mapper.relationships:
if prop.uselist:
continue
for col in prop.local_columns:
if col.name != field:
continue
if not hasattr(version, prop.key):
continue
if col in self.mapper.primary_key:
continue
ref = getattr(version, prop.key)
if ref:
ref = getattr(ref, 'version_parent', None)
if ref:
return HTML.tag('span', c=[
text,
HTML.tag('span', c=[str(ref)],
style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
])
return text
def render_old_value(self, field):
if self.nature == 'new':
return ''
value = self.old_value(field)
return self.render_version_value(field, value, self.version.previous)
def render_new_value(self, field):
if self.nature == 'deleted':
return ''
value = self.new_value(field)
return self.render_version_value(field, value, self.version)
def as_struct(self):
values = {}
for field in self.fields:
values[field] = {'before': self.render_old_value(field),
'after': self.render_new_value(field)}
operation = None
if self.version.operation_type == continuum.Operation.INSERT:
operation = 'INSERT'
elif self.version.operation_type == continuum.Operation.UPDATE:
operation = 'UPDATE'
elif self.version.operation_type == continuum.Operation.DELETE:
operation = 'DELETE'
else:
operation = self.version.operation_type
return {
'key': id(self.version),
'model_title': self.title,
'operation': operation,
'diff_class': self.nature,
'fields': self.fields,
'values': values,
}

View file

@ -1,49 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Exceptions
"""
from rattail.exceptions import RattailError
class TailboneError(RattailError):
"""
Base class for all Tailbone exceptions.
"""
class TailboneJSONFieldError(TailboneError):
"""
Error raised when JSON serialization of a form field results in an error.
This is just a simple wrapper, to make the error message more helpful for
the developer.
"""
def __init__(self, field, error):
self.field = field
self.error = error
def __str__(self):
return ("Failed to serialize field '{}' as JSON! "
"Original error was: {}".format(self.field, self.error))

View file

@ -1,30 +1,38 @@
# -*- coding: utf-8; -*-
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
# terms of the GNU 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 General Public License for more
# details.
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Forms Library
Forms
"""
# nb. import widgets before types, b/c types may refer to widgets
from . import widgets
from . import types
from .core import Form, SimpleFileImport
from __future__ import unicode_literals, absolute_import
from formencode import Schema
from .core import Form, Field, FieldSet, GenericFieldSet
from .simpleform import SimpleForm, FormRenderer
from .alchemy import AlchemyForm
from .fields import AssociationProxyField
from .renderers import *
from . import renderers
from . import validators

117
tailbone/forms/alchemy.py Normal file
View file

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
FormAlchemy Forms
"""
from __future__ import unicode_literals, absolute_import
from rattail.core import Object
import formalchemy as fa
from pyramid.renderers import render
from webhelpers.html import HTML, tags
from tailbone.db import Session
class TemplateEngine(fa.templates.TemplateEngine):
"""
Mako template engine for FormAlchemy.
"""
def render(self, template, prefix='/forms/', suffix='.mako', **kwargs):
template = ''.join((prefix, template, suffix))
return render(template, kwargs)
class AlchemyForm(Object):
"""
Form to contain a :class:`formalchemy.FieldSet` instance.
"""
id = None
create_label = "Create"
update_label = "Save"
allow_successive_creates = False
def __init__(self, request, fieldset, session=None, csrf_field='_csrf', **kwargs):
super(AlchemyForm, self).__init__(**kwargs)
self.request = request
self.fieldset = fieldset
self.session = session
self.csrf_field = csrf_field
def _get_readonly(self):
return self.fieldset.readonly
def _set_readonly(self, val):
self.fieldset.readonly = val
readonly = property(_get_readonly, _set_readonly)
@property
def successive_create_label(self):
return "%s and continue" % self.create_label
def csrf(self, name=None):
"""
NOTE: this method was copied from `pyramid_simpleform.FormRenderer`
Returns the CSRF hidden input. Creates new CSRF token
if none has been assigned yet.
The name of the hidden field is **_csrf** by default.
"""
name = name or self.csrf_field
token = self.request.session.get_csrf_token()
if token is None:
token = self.request.session.new_csrf_token()
return tags.hidden(name, value=token)
def csrf_token(self, name=None):
"""
NOTE: this method was copied from `pyramid_simpleform.FormRenderer`
Convenience function. Returns CSRF hidden tag inside hidden DIV.
"""
return HTML.tag("div", self.csrf(name), style="display:none;")
def render(self, **kwargs):
kwargs['form'] = self
if self.readonly:
template = '/forms/form_readonly.mako'
else:
template = '/forms/form.mako'
return render(template, kwargs)
def render_fields(self):
return self.fieldset.render()
def save(self):
self.fieldset.sync()
self.session.flush()
def validate(self):
self.fieldset.rebind(data=self.request.params)
return self.fieldset.validate()

View file

@ -1,62 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Common Forms
"""
from rattail.db import model
import colander
@colander.deferred
def validate_user(node, kw):
session = kw['session']
def validate(node, value):
user = session.get(model.User, value)
if not user:
raise colander.Invalid(node, "User not found")
return user.uuid
return validate
class Feedback(colander.Schema):
"""
Form schema for user feedback.
"""
email_key = colander.SchemaNode(colander.String(),
missing=colander.null)
referrer = colander.SchemaNode(colander.String())
user = colander.SchemaNode(colander.String(),
missing=colander.null,
validator=validate_user)
user_name = colander.SchemaNode(colander.String(),
missing=colander.null)
please_reply_to = colander.SchemaNode(colander.String(),
missing=colander.null)
message = colander.SchemaNode(colander.String())

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Form Objects for Customer Orders
"""
from __future__ import unicode_literals
from rattail import enum
from rattail.db import model
import formencode
from formencode import validators
from tailbone.forms.validators import ValidCustomer, ValidProduct, ValidUser
class ValidCustomerInfo(validators.FormValidator):
"""
Custom validator to ensure we have either a proper customer reference, or
at least a customer name, when creating a new customer order.
"""
def validate_python(self, field_dict, state):
if not field_dict['customer'] and not field_dict['customer_name']:
raise formencode.Invalid("Customer name is required", field_dict, state)
class NewCustomerOrderItem(formencode.Schema):
"""
Form schema to which individual items on a new customer order must adhere.
"""
allow_extra_fields = True
product = ValidProduct()
product_description = validators.NotEmpty()
quantity = validators.Int()
unit_of_measure = validators.OneOf(enum.UNIT_OF_MEASURE)
discount = validators.Int()
notes = validators.String()
class NewCustomerOrder(formencode.Schema):
"""
Form schema for creating a new customer order.
"""
allow_extra_fields = True
pre_validators = [formencode.NestedVariables()]
user = formencode.Pipe(validators=[
validators.NotEmpty(),
ValidUser()])
customer = ValidCustomer()
customer_name = validators.String()
customer_phone = validators.NotEmpty()
products = formencode.ForEach(NewCustomerOrderItem())
chained_validators = [ValidCustomerInfo()]

49
tailbone/forms/fields.py Normal file
View file

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
FormAlchemy Fields
"""
from __future__ import unicode_literals, absolute_import
from formalchemy import Field
def AssociationProxyField(name, **kwargs):
"""
Returns a FormAlchemy ``Field`` class which is aware of association
proxies.
"""
class ProxyField(Field):
def sync(self):
if not self.is_readonly():
setattr(self.parent.model, self.name,
self.renderer.deserialize())
def value(model):
return getattr(model, name, None)
kwargs.setdefault('value', value)
return ProxyField(name, **kwargs)

View file

@ -1,68 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Forms for Receiving
"""
from rattail.db import model
import colander
@colander.deferred
def valid_purchase_batch_row(node, kw):
session = kw['session']
def validate(node, value):
row = session.get(model.PurchaseBatchRow, value)
if not row:
raise colander.Invalid(node, "Batch row not found")
if row.batch.executed:
raise colander.Invalid(node, "Batch has already been executed")
return row.uuid
return validate
class ReceiveRow(colander.MappingSchema):
row = colander.SchemaNode(colander.String(),
validator=valid_purchase_batch_row)
mode = colander.SchemaNode(colander.String(),
validator=colander.OneOf([
'received',
'damaged',
'expired',
'missing',
# 'mispick',
]))
cases = colander.SchemaNode(colander.Decimal(),
missing=colander.null)
units = colander.SchemaNode(colander.Decimal(),
missing=colander.null)
expiration_date = colander.SchemaNode(colander.Date(),
missing=colander.null)
quick_receive = colander.SchemaNode(colander.Boolean())

View file

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
FormAlchemy Field Renderers
"""
from __future__ import unicode_literals, absolute_import
from .core import CustomFieldRenderer, DateFieldRenderer
from .common import (StrippedTextFieldRenderer, CodeTextAreaFieldRenderer, AutocompleteFieldRenderer,
DecimalFieldRenderer, CurrencyFieldRenderer, QuantityFieldRenderer,
DateTimeFieldRenderer, DateTimePrettyFieldRenderer, TimeFieldRenderer,
EnumFieldRenderer, YesNoFieldRenderer)
from .files import FileFieldRenderer
from .people import PersonFieldRenderer, CustomerFieldRenderer
from .users import UserFieldRenderer, PermissionsFieldRenderer
from .employees import EmployeeFieldRenderer
from .stores import StoreFieldRenderer
from .vendors import VendorFieldRenderer, PurchaseFieldRenderer
from .products import (GPCFieldRenderer, ScancodeFieldRenderer,
DepartmentFieldRenderer, SubdepartmentFieldRenderer, CategoryFieldRenderer,
BrandFieldRenderer, ProductFieldRenderer,
PriceFieldRenderer, PriceWithExpirationFieldRenderer)
from .custorders import CustomerOrderFieldRenderer
from .batch import BatchIDFieldRenderer, HandheldBatchFieldRenderer

View file

@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Batch Field Renderers
"""
from __future__ import unicode_literals, absolute_import
import os
import stat
import random
import formalchemy as fa
from formalchemy.ext import fsblob
from formalchemy.fields import FileFieldRenderer as Base
from webhelpers.html import tags
class BatchIDFieldRenderer(fa.FieldRenderer):
"""
Renderer for batch ID fields.
"""
def render_readonly(self, **kwargs):
try:
batch_id = self.raw_value
except AttributeError:
# this can happen when creating a new batch, b/c the default value
# comes from a sequence
pass
else:
if batch_id:
return '{:08d}'.format(batch_id)
return ''
# TODO: make this inherit from `tailbone.forms.renderers.files.FileFieldRenderer`
class FileFieldRenderer(fsblob.FileFieldRenderer):
"""
Custom file field renderer for batches based on a single source data file.
In edit mode, shows a file upload field. In readonly mode, shows the
filename and its size.
"""
@classmethod
def new(cls, view):
name = 'Configured%s_%s' % (cls.__name__, str(random.random())[2:])
return type(str(name), (cls,), dict(view=view))
@property
def storage_path(self):
return self.view.upload_dir
def get_size(self):
size = super(FileFieldRenderer, self).get_size()
if size:
return size
batch = self.field.parent.model
path = os.path.join(self.view.handler.datadir(batch), self.field.value)
if os.path.isfile(path):
return os.stat(path)[stat.ST_SIZE]
return 0
def get_url(self, filename):
batch = self.field.parent.model
return self.view.request.route_url('{}.download'.format(self.view.get_route_prefix()),
uuid=batch.uuid)
def render(self, **kwargs):
return Base.render(self, **kwargs)
class HandheldBatchFieldRenderer(fa.FieldRenderer):
"""
Renderer for inventory batch's "handheld batch" field.
"""
def render_readonly(self, **kwargs):
batch = self.raw_value
if batch:
return tags.link_to(
batch.id_str,
self.request.route_url('batch.handheld.view', uuid=batch.uuid))
return ''

View file

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Batch Field Renderers
"""
from __future__ import unicode_literals
import os
import stat
import random
from formalchemy.ext import fsblob
class BounceMessageFieldRenderer(fsblob.FileFieldRenderer):
"""
Custom file field renderer for email bounce messages. In readonly mode,
shows the filename and size.
"""
@classmethod
def new(cls, request, handler):
name = 'Configured%s_%s' % (cls.__name__, unicode(random.random())[2:])
return type(str(name), (cls,), dict(request=request, handler=handler))
@property
def storage_path(self):
return self.handler.root_msgdir
def get_size(self):
size = super(BounceMessageFieldRenderer, self).get_size()
if size:
return size
bounce = self.field.parent.model
path = os.path.join(self.handler.msgpath(bounce))
if os.path.isfile(path):
return os.stat(path)[stat.ST_SIZE]
return 0
def get_url(self, filename):
bounce = self.field.parent.model
return self.request.route_url('emailbounces.download', uuid=bounce.uuid)

View file

@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Common Field Renderers
"""
from __future__ import unicode_literals, absolute_import
import datetime
from rattail.time import localtime, make_utc
from rattail.util import pretty_quantity
import formalchemy as fa
from formalchemy import fields as fa_fields, helpers as fa_helpers
from pyramid.renderers import render
from webhelpers.html import HTML
from tailbone.util import pretty_datetime, raw_datetime
class StrippedTextFieldRenderer(fa.TextFieldRenderer):
"""
Standard text field renderer, which strips whitespace from either end of
the input value on deserialization.
"""
def deserialize(self):
value = super(StrippedTextFieldRenderer, self).deserialize()
if value is not None:
return value.strip()
class CodeTextAreaFieldRenderer(fa.TextAreaFieldRenderer):
def render_readonly(self, **kwargs):
value = self.raw_value
if not value:
return ''
return HTML.tag('pre', c=value)
def render(self, **kwargs):
kwargs.setdefault('size', (80, 8))
return super(CodeTextAreaFieldRenderer, self).render(**kwargs)
class AutocompleteFieldRenderer(fa.FieldRenderer):
"""
Custom renderer for an autocomplete field.
"""
service_route = None
width = '300px'
@property
def focus_name(self):
return self.name + '-textbox'
@property
def needs_focus(self):
return not bool(self.value or self.field_value)
@property
def field_display(self):
return self.raw_value
@property
def field_value(self):
return self.value
@property
def service_url(self):
return self.request.route_url(self.service_route)
def render(self, **kwargs):
kwargs.setdefault('field_name', self.name)
kwargs.setdefault('field_value', self.field_value)
kwargs.setdefault('field_display', self.field_display)
kwargs.setdefault('service_url', self.service_url)
kwargs.setdefault('width', self.width)
return render('/forms/field_autocomplete.mako', kwargs)
def render_readonly(self, **kwargs):
value = self.field_display
if value is None:
return u''
return unicode(value)
class DateTimeFieldRenderer(fa.DateTimeFieldRenderer):
"""
This renderer assumes the datetime field value is in UTC, and will convert
it to the local time zone before rendering it in the standard "raw" format.
"""
def render_readonly(self, **kwargs):
value = self.raw_value
if not value:
return ''
return raw_datetime(self.request.rattail_config, value)
class DateTimePrettyFieldRenderer(fa.DateTimeFieldRenderer):
"""
Custom date/time field renderer, which displays a "pretty" value in
read-only mode, leveraging config to show the correct timezone.
"""
def render_readonly(self, **kwargs):
value = self.raw_value
if not value:
return ''
return pretty_datetime(self.request.rattail_config, value)
class TimeFieldRenderer(fa.TimeFieldRenderer):
"""
Custom renderer for time fields. In edit mode, renders a simple text
input, which is expected to become a 'timepicker' widget in the UI.
However the particular magic required for that lives in 'tailbone.js'.
"""
format = '%I:%M %p'
def render(self, **kwargs):
kwargs.setdefault('class_', 'timepicker')
return fa_helpers.text_field(self.name, value=self.value, **kwargs)
def render_readonly(self, **kwargs):
return self.render_value(self.raw_value)
def render_value(self, value):
value = self.convert_value(value)
if isinstance(value, datetime.time):
return value.strftime(self.format)
return ''
def convert_value(self, value):
if isinstance(value, datetime.datetime):
if not value.tzinfo:
value = make_utc(value, tzinfo=True)
return localtime(self.request.rattail_config, value).time()
return value
def stringify_value(self, value, as_html=False):
if not as_html:
return self.render_value(value)
return super(TimeFieldRenderer, self).stringify_value(value, as_html=as_html)
def _serialized_value(self):
return self.params.getone(self.name)
def deserialize(self):
value = self._serialized_value()
if value:
try:
return datetime.datetime.strptime(value, self.format).time()
except ValueError:
pass
class EnumFieldRenderer(fa_fields.SelectFieldRenderer):
"""
Renderer for simple enumeration fields.
"""
enumeration = {}
render_key = False
def __init__(self, arg, render_key=False):
if isinstance(arg, dict):
self.enumeration = arg
self.render_key = render_key
else:
self(arg)
def __call__(self, field):
super(EnumFieldRenderer, self).__init__(field)
return self
def render_readonly(self, **kwargs):
value = self.raw_value
if value is None:
return ''
rendered = self.enumeration.get(value, unicode(value))
if self.render_key:
rendered = '{} - {}'.format(value, rendered)
return rendered
def render(self, **kwargs):
opts = [(self.enumeration[x], x) for x in self.enumeration]
if not self.field.is_required():
opts.insert(0, self.field._null_option)
return fa_fields.SelectFieldRenderer.render(self, opts, **kwargs)
class DecimalFieldRenderer(fa.FieldRenderer):
"""
Sort of generic field renderer for decimal values. You must provide the
number of places after the decimal (scale). Note that this in turn relies
on simple string formatting; the renderer does not attempt any mathematics
of its own.
"""
def __init__(self, scale):
self.scale = scale
def __call__(self, field):
super(DecimalFieldRenderer, self).__init__(field)
return self
def render_readonly(self, **kwargs):
value = self.raw_value
if value is None:
return ''
fmt = '{{0:0.{0}f}}'.format(self.scale)
return fmt.format(value)
class CurrencyFieldRenderer(fa_fields.FloatFieldRenderer):
"""
Sort of generic field renderer for currency values.
"""
def render_readonly(self, **kwargs):
value = self.raw_value
if value is None:
return ''
if value < 0:
return "(${:0,.2f})".format(0 - value)
return "${:0,.2f}".format(value)
class QuantityFieldRenderer(fa_fields.FloatFieldRenderer):
"""
Sort of generic field renderer for quantity values.
"""
def render_readonly(self, **kwargs):
return pretty_quantity(self.raw_value)
class YesNoFieldRenderer(fa.CheckBoxFieldRenderer):
def render_readonly(self, **kwargs):
value = self.raw_value
if value is None:
return u''
return u'Yes' if value else u'No'

View file

@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Core Field Renderers
"""
from __future__ import unicode_literals, absolute_import
import datetime
import formalchemy as fa
from formalchemy.fields import AbstractField
from pyramid.renderers import render
class CustomFieldRenderer(fa.FieldRenderer):
"""
Base class for renderers which accept customization args, and "fake out"
FormAlchemy by pretending to still be a renderer factory when in fact it's
already dealing with a renderer instance.
"""
def __init__(self, *args, **kwargs):
if len(args) == 1 and isinstance(args[0], AbstractField):
super(CustomFieldRenderer, self).__init__(args[0])
self.init(**kwargs)
else:
assert len(args) == 0
self.init(**kwargs)
def __call__(self, field):
super(CustomFieldRenderer, self).__init__(field)
return self
def init(self, **kwargs):
pass
@property
def rattail_config(self):
return self.request.rattail_config
class DateFieldRenderer(CustomFieldRenderer):
"""
Date field renderer which uses jQuery UI datepicker widget when rendering
in edit mode.
"""
date_format = None
change_year = False
def init(self, date_format=None, change_year=False):
self.date_format = date_format
self.change_year = change_year
def render_readonly(self, **kwargs):
value = self.raw_value
if value is None:
return ''
return value.strftime(self.date_format)
def render(self, **kwargs):
kwargs['name'] = self.name
kwargs['value'] = self.value
kwargs['change_year'] = self.change_year
return render('/forms/fields/date.mako', kwargs)
def deserialize(self):
value = self._serialized_value()
if not value:
return None
try:
return datetime.datetime.strptime(value, '%Y-%m-%d')
except ValueError:
raise fa.ValidationError("Date value must be in YYYY-MM-DD format")
except Exception as error:
raise fa.ValidationError(unicode(error))
def _serialized_value(self):
return self.params.getone(self.name)

View file

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Customer order field renderers
"""
from __future__ import unicode_literals, absolute_import
import formalchemy as fa
from webhelpers.html import tags
class CustomerOrderFieldRenderer(fa.fields.SelectFieldRenderer):
"""
Renders a link to the customer order
"""
def render_readonly(self, **kwargs):
order = self.raw_value
if not order:
return ''
return tags.link_to(order, self.request.route_url('custorders.view', uuid=order.uuid))

View file

@ -0,0 +1,47 @@
# -*- 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 Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Employee Field Renderers
"""
from __future__ import unicode_literals, absolute_import
from webhelpers.html import tags
from tailbone.forms.renderers import AutocompleteFieldRenderer
class EmployeeFieldRenderer(AutocompleteFieldRenderer):
"""
Renderer for :class:`rattail.db.model.Employee` instance fields.
"""
service_route = 'employees.autocomplete'
def render_readonly(self, **kwargs):
employee = self.raw_value
if not employee:
return ''
title = unicode(employee.person)
if self.request.has_perm('employees.view'):
return tags.link_to(title, self.request.route_url('employees.view', uuid=employee.uuid))
return title

View file

@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Batch Field Renderers
"""
from __future__ import unicode_literals, absolute_import
import os
import stat
import random
from formalchemy.ext import fsblob
from formalchemy.fields import FileFieldRenderer as Base
class FileFieldRenderer(fsblob.FileFieldRenderer):
"""
Custom file field renderer. In readonly mode, shows a filename and its
size; in edit mode, supports a single file upload.
"""
@classmethod
def new(cls, view, **kwargs):
name = b'Configured{}_{}'.format(cls.__name__, str(random.random())[2:])
return type(name, (cls,), dict(view=view, **kwargs))
@property
def request(self):
return self.view.request
@property
def storage_path(self):
return self.view.upload_dir
def get_file_path(self):
"""
Returns the absolute path to the data file.
"""
if hasattr(self, 'file_path'):
return self.file_path
return self.field.value
def get_size(self):
"""
Returns the size of the data file, in bytes.
"""
path = self.get_file_path()
if path and os.path.isfile(path):
return os.stat(path)[stat.ST_SIZE]
return 0
def get_url(self, filename):
url = self.get_download_url()
if url:
if callable(url):
return url(filename)
return url
def get_download_url(self):
if hasattr(self, 'download_url'):
return self.download_url
def render(self, **kwargs):
return Base.render(self, **kwargs)
def render_readonly(self, **kwargs):
"""
Render the filename and the binary size in a human readable with a link
to the file itself.
"""
value = self.get_file_path()
if value:
content = '{} ({})'.format(fsblob.normalized_basename(value),
self.readable_size())
return fsblob.h.content_tag('a', content,
href=self.get_url(value), **kwargs)
return ''

View file

@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
People Field Renderers
"""
from __future__ import unicode_literals, absolute_import
from webhelpers.html import tags
from tailbone.forms.renderers.common import AutocompleteFieldRenderer
class PersonFieldRenderer(AutocompleteFieldRenderer):
"""
Renderer for :class:`rattail.db.model.Person` instance fields.
"""
service_route = 'people.autocomplete'
def render_readonly(self, **kwargs):
person = self.raw_value
if not person:
return ''
return tags.link_to(person, self.request.route_url('people.view', uuid=person.uuid))
class CustomerFieldRenderer(AutocompleteFieldRenderer):
"""
Renderer for :class:`rattail.db.model.Customer` instance fields.
"""
service_route = 'customers.autocomplete'
def render_readonly(self, **kwargs):
customer = self.raw_value
if not customer:
return ''
return tags.link_to(customer, self.request.route_url('customers.view', uuid=customer.uuid))

View file

@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Product Field Renderers
"""
from __future__ import unicode_literals, absolute_import
from rattail.gpc import GPC
from rattail.db import model
from rattail.db.util import maxlen
from formalchemy import TextFieldRenderer
from formalchemy.fields import SelectFieldRenderer
from webhelpers.html import tags, literal
from tailbone.forms.renderers.common import AutocompleteFieldRenderer
from tailbone.util import pretty_datetime
class ProductFieldRenderer(AutocompleteFieldRenderer):
"""
Renderer for :class:`rattail.db.model.Product` instance fields.
"""
service_route = 'products.autocomplete'
@property
def field_display(self):
product = self.raw_value
if product:
return product.full_description
return ''
def render_readonly(self, **kwargs):
product = self.raw_value
if not product:
return ''
return tags.link_to(product, self.request.route_url('products.view', uuid=product.uuid))
class ProductKeyFieldRenderer(TextFieldRenderer):
"""
Base class for product key field renderers.
"""
def render_readonly(self, **kwargs):
value = self.raw_value
if value is None:
return ''
value = self.render_value(value)
if kwargs.get('link'):
product = self.field.parent.model
value = tags.link_to(value, kwargs['link'](product))
return value
def render_value(self, value):
return unicode(value)
class GPCFieldRenderer(ProductKeyFieldRenderer):
"""
Renderer for :class:`rattail.gpc.GPC` fields.
"""
@property
def length(self):
# Hm, should maybe consider hard-coding this...?
return len(unicode(GPC(0)))
def render_value(self, gpc):
return gpc.pretty()
class ScancodeFieldRenderer(ProductKeyFieldRenderer):
"""
Renderer for :class:`rattail.db.model.Product.scancode` field
"""
@property
def length(self):
return maxlen(model.Product.scancode)
class DepartmentFieldRenderer(SelectFieldRenderer):
"""
Shows the department number as well as the name.
"""
def render_readonly(self, **kwargs):
department = self.raw_value
if not department:
return ''
if department.number:
text = '({}) {}'.format(department.number, department.name)
else:
text = department.name
return tags.link_to(text, self.request.route_url('departments.view', uuid=department.uuid))
class SubdepartmentFieldRenderer(SelectFieldRenderer):
"""
Shows a link to the subdepartment.
"""
def render_readonly(self, **kwargs):
subdept = self.raw_value
if not subdept:
return ""
if subdept.number:
text = "({}) {}".format(subdept.number, subdept.name)
else:
text = subdept.name
return tags.link_to(text, self.request.route_url('subdepartments.view', uuid=subdept.uuid))
class CategoryFieldRenderer(SelectFieldRenderer):
"""
Shows a link to the category.
"""
def render_readonly(self, **kwargs):
category = self.raw_value
if not category:
return ""
if category.code:
text = "({}) {}".format(category.code, category.name)
else:
text = category.name
return tags.link_to(text, self.request.route_url('categories.view', uuid=category.uuid))
class BrandFieldRenderer(AutocompleteFieldRenderer):
"""
Renderer for :class:`rattail.db.model.Brand` instance fields.
"""
service_route = 'brands.autocomplete'
class PriceFieldRenderer(TextFieldRenderer):
"""
Renderer for fields which reference a :class:`ProductPrice` instance.
"""
def render_readonly(self, **kwargs):
price = self.field.raw_value
if price:
if not price.product.not_for_sale:
if price.price is not None and price.pack_price is not None:
if price.multiple > 1:
return literal('$ %0.2f / %u&nbsp; ($ %0.2f / %u)' % (
price.price, price.multiple,
price.pack_price, price.pack_multiple))
return literal('$ %0.2f&nbsp; ($ %0.2f / %u)' % (
price.price, price.pack_price, price.pack_multiple))
if price.price is not None:
if price.multiple > 1:
return '$ %0.2f / %u' % (price.price, price.multiple)
return '$ %0.2f' % price.price
if price.pack_price is not None:
return '$ %0.2f / %u' % (price.pack_price, price.pack_multiple)
return ''
class PriceWithExpirationFieldRenderer(PriceFieldRenderer):
"""
Price field renderer which also displays the expiration date, if present.
"""
def render_readonly(self, **kwargs):
result = super(PriceWithExpirationFieldRenderer, self).render_readonly(**kwargs)
if result:
price = self.field.raw_value
if price.ends:
result = '{0}&nbsp; ({1})'.format(
result, pretty_datetime(self.request.rattail_config, price.ends))
return result

View file

@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Store Field Renderers
"""
from __future__ import unicode_literals
from formalchemy.fields import SelectFieldRenderer
class StoreFieldRenderer(SelectFieldRenderer):
"""
Renderer for :class:`rattail.db.model.Store` instance fields.
"""
def render_readonly(self, **kwargs):
store = self.raw_value
if not store:
return ''
return '{0} - {1}'.format(store.id, store.name)

View file

@ -0,0 +1,95 @@
# -*- 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 Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
User Field Renderers
"""
from __future__ import unicode_literals, absolute_import
from rattail.db import model
from rattail.db.auth import has_permission, administrator_role
import formalchemy
from webhelpers.html import HTML, tags
from tailbone.db import Session
class UserFieldRenderer(formalchemy.TextFieldRenderer):
"""
Renderer for :class:`rattail:rattail.db.model.User` instance fields.
"""
def render_readonly(self, **kwargs):
user = self.raw_value
if not user:
return ''
title = user.display_name
if kwargs.get('hyperlink') and self.request.has_perm('users.view'):
return tags.link_to(title, self.request.route_url('users.view', uuid=user.uuid))
return title
def PermissionsFieldRenderer(permissions, include_guest=False, include_authenticated=False):
class PermissionsFieldRenderer(formalchemy.FieldRenderer):
def deserialize(self):
perms = []
i = len(self.name) + 1
for key in self.params:
if key.startswith(self.name):
perms.append(key[i:])
return perms
def _render(self, readonly=False, **kwargs):
principal = self.field.model
html = ''
for groupkey in sorted(permissions, key=lambda k: permissions[k]['label'].lower()):
inner = HTML.tag('p', c=permissions[groupkey]['label'])
perms = permissions[groupkey]['perms']
rendered = False
for key in sorted(perms, key=lambda p: perms[p]['label'].lower()):
checked = has_permission(Session(), principal, key,
include_guest=include_guest,
include_authenticated=include_authenticated)
if checked or not readonly:
label = perms[key]['label']
if readonly:
span = HTML.tag('span', c="[X]" if checked else "[ ]")
inner += HTML.tag('p', class_='perm', c=span + ' ' + label)
else:
inner += tags.checkbox(self.name + '-' + key,
checked=checked, label=label)
rendered = True
if rendered:
html += HTML.tag('div', class_='group', c=inner)
return html or "(none granted)"
def render(self, **kwargs):
return self._render(**kwargs)
def render_readonly(self, **kwargs):
return self._render(readonly=True, **kwargs)
return PermissionsFieldRenderer

View file

@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Vendor Field Renderers
"""
from __future__ import unicode_literals, absolute_import
from formalchemy.fields import SelectFieldRenderer
from webhelpers.html import tags
from tailbone.forms.renderers.common import AutocompleteFieldRenderer
class VendorFieldRenderer(AutocompleteFieldRenderer):
"""
Renderer for :class:`rattail.db.model.Vendor` instance fields.
"""
service_route = 'vendors.autocomplete'
def render_readonly(self, **kwargs):
vendor = self.raw_value
if not vendor:
return ''
return tags.link_to(vendor, self.request.route_url('vendors.view', uuid=vendor.uuid))
class PurchaseFieldRenderer(SelectFieldRenderer):
"""
Renderer for :class:`rattail.db.model.Purchase` relation fields.
"""
def render_readonly(self, **kwargs):
purchase = self.raw_value
if not purchase:
return ''
return tags.link_to(purchase, self.request.route_url('purchases.view', uuid=purchase.uuid))

View file

@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Simple Forms
"""
from __future__ import unicode_literals, absolute_import
from rattail.util import prettify
import pyramid_simpleform
from pyramid_simpleform import renderers
from webhelpers.html import tags
from webhelpers.html import HTML
from tailbone.forms import Form
class SimpleForm(Form):
"""
Customized simple form.
"""
def __init__(self, request, schema, obj=None, **kwargs):
super(SimpleForm, self).__init__(request, **kwargs)
self._form = pyramid_simpleform.Form(request, schema=schema, obj=obj)
def __getattr__(self, attr):
return getattr(self._form, attr)
def render(self, **kwargs):
kwargs['form'] = FormRenderer(self)
return super(SimpleForm, self).render(**kwargs)
def validate(self):
return self._form.validate()
class FormRenderer(renderers.FormRenderer):
"""
Customized form renderer. Provides some extra methods for convenience.
"""
def __getattr__(self, attr):
return getattr(self.form, attr)
def field_div(self, name, field, label=None):
errors = self.errors_for(name)
if errors:
errors = [HTML.tag('div', class_='field-error', c=x) for x in errors]
errors = tags.literal('').join(errors)
label = HTML.tag('label', for_=name, c=label or prettify(name))
inner = HTML.tag('div', class_='field', c=field)
outer_class = 'field-wrapper'
if errors:
outer_class += ' error'
outer = HTML.tag('div', class_=outer_class, c=(errors or '') + label + inner)
return outer
def referrer_field(self):
return self.hidden('referrer', value=self.form.request.get_referrer())

View file

@ -1,265 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Form Schema Types
"""
import re
import datetime
import json
from rattail.db import model
from rattail.gpc import GPC
import colander
from tailbone.db import Session
from tailbone.forms import widgets
class JQueryTime(colander.Time):
"""
Custom type for jQuery widget Time data.
"""
def deserialize(self, node, cstruct):
if not cstruct:
return colander.null
formats = [
'%I:%M %p',
'%I:%M%p',
'%I %p',
'%I%p',
]
for fmt in formats:
try:
return datetime.datetime.strptime(cstruct, fmt).time()
except ValueError:
pass
# re-try first format, for "better" error message
return datetime.datetime.strptime(cstruct, formats[0]).time()
class DateTimeBoolean(colander.Boolean):
"""
Schema type which presents the user with a "boolean" whereas the underlying
node is really a datetime (assumed to be "naive" UTC, and allow nulls).
"""
def deserialize(self, node, cstruct):
value = super(DateTimeBoolean, self).deserialize(node, cstruct)
if value: # else return None
return datetime.datetime.utcnow()
class FalafelDateTime(colander.DateTime):
"""
Custom schema node type for rattail UTC datetimes
"""
widget_maker = widgets.FalafelDateTimeWidget
def __init__(self, *args, **kwargs):
request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.request = request
def serialize(self, node, appstruct):
if not appstruct:
return {}
# cant use isinstance; dt subs date
if type(appstruct) is datetime.date:
appstruct = datetime.datetime.combine(appstruct, datetime.time())
if not isinstance(appstruct, datetime.datetime):
raise colander.Invalid(node, f'"{appstruct}" is not a datetime object')
if appstruct.tzinfo is None:
appstruct = appstruct.replace(tzinfo=self.default_tzinfo)
app = self.request.rattail_config.get_app()
dt = app.localtime(appstruct, from_utc=True)
return {
'date': str(dt.date()),
'time': str(dt.time()),
}
def deserialize(self, node, cstruct):
if not cstruct:
return colander.null
if not cstruct['date'] and not cstruct['time']:
return colander.null
try:
date = datetime.datetime.strptime(cstruct['date'], '%Y-%m-%d').date()
except:
node.raise_invalid("Missing or invalid date")
try:
time = datetime.datetime.strptime(cstruct['time'], '%H:%M:%S').time()
except:
node.raise_invalid("Missing or invalid time")
result = datetime.datetime.combine(date, time)
app = self.request.rattail_config.get_app()
result = app.localtime(result)
result = app.make_utc(result)
return result
class FalafelTime(colander.Time):
"""
Custom schema node type for simple time fields
"""
widget_maker = widgets.FalafelTimeWidget
def __init__(self, *args, **kwargs):
request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.request = request
class GPCType(colander.SchemaType):
"""
Schema type for product GPC data.
"""
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return str(appstruct)
def deserialize(self, node, cstruct):
if not cstruct:
return None
digits = re.sub(r'\D', '', cstruct)
if not digits:
return None
try:
return GPC(digits)
except Exception as err:
raise colander.Invalid(node, str(err))
class ProductQuantity(colander.MappingSchema):
"""
Combo schema type for product cases and units; useful for inventory,
ordering, receiving etc. Meant to be used with the ``CasesUnitsWidget``.
"""
cases = colander.SchemaNode(colander.Decimal(), missing=colander.null)
units = colander.SchemaNode(colander.Decimal(), missing=colander.null)
class ModelType(colander.SchemaType):
"""
Custom schema type for scalar ORM relationship fields.
"""
model_class = None
session = None
def __init__(self, model_class=None, session=None):
if model_class:
self.model_class = model_class
if session:
self.session = session
else:
self.session = self.make_session()
def make_session(self):
return Session()
@property
def model_title(self):
self.model_class.get_model_title()
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return str(appstruct)
def deserialize(self, node, cstruct):
if not cstruct:
return None
obj = self.session.get(self.model_class, cstruct)
if not obj:
raise colander.Invalid(node, "{} not found".format(self.model_title))
return obj
# TODO: deprecate / remove this
ObjectType = ModelType
class StoreType(ModelType):
"""
Custom schema type for store field.
"""
model_class = model.Store
class CustomerType(ModelType):
"""
Custom schema type for customer field.
"""
model_class = model.Customer
class DepartmentType(ModelType):
"""
Custom schema type for department field.
"""
model_class = model.Department
class EmployeeType(ModelType):
"""
Custom schema type for employee field.
"""
model_class = model.Employee
class VendorType(ModelType):
"""
Custom schema type for vendor relationship field.
"""
model_class = model.Vendor
class ProductType(ModelType):
"""
Custom schema type for product relationship field.
"""
model_class = model.Product
class UserType(ModelType):
"""
Custom schema type for user field.
"""
model_class = model.User

View file

@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Custom Form Validators
"""
from __future__ import unicode_literals, absolute_import
import re
from rattail.db import model
from rattail.db.util import validate_email_address, validate_phone_number
from rattail.gpc import GPC
import formencode as fe
import formalchemy as fa
from tailbone.db import Session
class ValidGPC(fe.validators.FancyValidator):
"""
Validator for fields which should contain GPC value.
"""
def _to_python(self, value, state):
if value is not None:
digits = re.sub(r'\D', '', value)
if digits:
try:
return GPC(digits)
except ValueError as error:
raise fe.Invalid("Invalid UPC: {}".format(error), value, state)
def _from_python(self, upc, state):
if upc is None:
return ''
return upc.pretty()
class ModelValidator(fe.validators.FancyValidator):
"""
Generic validator for data model reference fields.
"""
model_class = None
@property
def model_name(self):
self.model_class.__name__
def _to_python(self, value, state):
if value:
obj = Session.query(self.model_class).get(value)
if obj:
return obj
raise fe.Invalid("{} not found".format(self.model_name), value, state)
def _from_python(self, value, state):
obj = value
if not obj:
return ''
return obj.uuid
def validate_python(self, value, state):
obj = value
if obj is not None and not isinstance(obj, self.model_class):
raise fe.Invalid("Value must be a valid {} object".format(self.model_name), value, state)
class ValidStore(ModelValidator):
"""
Validator for store field.
"""
model_class = model.Store
class ValidCustomer(ModelValidator):
"""
Validator for customer field.
"""
model_class = model.Customer
class ValidDepartment(ModelValidator):
"""
Validator for department field.
"""
model_class = model.Department
class ValidEmployee(ModelValidator):
"""
Validator for employee field.
"""
model_class = model.Employee
class ValidProduct(ModelValidator):
"""
Validator for product field.
"""
model_class = model.Product
class ValidUser(ModelValidator):
"""
Validator for user field.
"""
model_class = model.User
def valid_email_address(value, field=None):
"""
FormAlchemy-compatible validation function, which leverages FormEncode
under the hood.
"""
if value:
try:
return validate_email_address(value, error=True)
except Exception as error:
raise fa.ValidationError(unicode(error))
def valid_phone_number(value, field=None):
"""
FormAlchemy-compatible validation function, which leverages FormEncode
under the hood.
"""
if value:
try:
return validate_phone_number(value, error=True)
except Exception as error:
raise fa.ValidationError(unicode(error))

View file

@ -1,659 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Form Widgets
"""
import json
import datetime
import decimal
import re
import colander
from deform import widget as dfwidget
from webhelpers2.html import tags, HTML
from tailbone.db import Session
class ReadonlyWidget(dfwidget.HiddenWidget):
readonly = True
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
# TODO: is this hacky?
text = kw.get('text')
if not text:
text = field.parent.tailbone_form.render_field_value(field.name)
return HTML.tag('span', text) + tags.hidden(field.name, value=cstruct, id=field.oid)
class NumberInputWidget(dfwidget.TextInputWidget):
template = 'numberinput'
autocomplete = 'off'
class NumericInputWidget(NumberInputWidget):
"""
This widget uses a ``<numeric-input>`` component, which will
leverage the ``numeric.js`` functions to ensure user doesn't enter
any non-numeric values. Note that this still uses a normal "text"
input on the HTML side, as opposed to a "number" input, since the
latter is a bit ugly IMHO.
"""
template = 'numericinput'
allow_enter = True
class PercentInputWidget(dfwidget.TextInputWidget):
"""
Custom text input widget, used for "percent" type fields. This widget
assumes that the underlying storage for the value is a "traditional"
percent value, e.g. ``0.36135`` - but the UI should represent this as a
"human-friendly" value, e.g. ``36.135 %``.
"""
template = 'percentinput'
autocomplete = 'off'
def serialize(self, field, cstruct, **kw):
""" """
if cstruct not in (colander.null, None):
# convert "traditional" value to "human-friendly"
value = decimal.Decimal(cstruct) * 100
value = value.quantize(decimal.Decimal('0.001'))
cstruct = str(value)
return super().serialize(field, cstruct, **kw)
def deserialize(self, field, pstruct):
""" """
pstruct = super().deserialize(field, pstruct)
if pstruct is colander.null:
return colander.null
# convert "human-friendly" value to "traditional"
try:
value = decimal.Decimal(pstruct)
except decimal.InvalidOperation:
raise colander.Invalid(field.schema, "Invalid decimal string: {}".format(pstruct))
value = value.quantize(decimal.Decimal('0.00001'))
value /= 100
return str(value)
class CasesUnitsWidget(dfwidget.Widget):
"""
Widget for collecting case and/or unit quantities. Most useful when you
need to ensure user provides cases *or* units but not both.
"""
template = 'cases_units'
amount_required = False
one_amount_only = False
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
readonly = kw.get('readonly', self.readonly)
kw['cases'] = cstruct['cases'] or ''
kw['units'] = cstruct['units'] or ''
template = readonly and self.readonly_template or self.template
values = self.get_template_values(field, cstruct, kw)
return field.renderer(template, **values)
def deserialize(self, field, pstruct):
""" """
from tailbone.forms.types import ProductQuantity
if pstruct is colander.null:
return colander.null
schema = ProductQuantity()
try:
validated = schema.deserialize(pstruct)
except colander.Invalid as exc:
raise colander.Invalid(field.schema, "Invalid pstruct: %s" % exc)
if self.amount_required and not (validated['cases'] or validated['units']):
raise colander.Invalid(field.schema, "Must provide case or unit amount",
value=validated)
if self.amount_required and self.one_amount_only and validated['cases'] and validated['units']:
raise colander.Invalid(field.schema, "Must provide case *or* unit amount, "
"but *not* both", value=validated)
return validated
class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
"""
This checkbox widget can be "dynamic" in the sense that form logic can
control its value and state.
"""
template = 'checkbox_dynamic'
# TODO: deprecate / remove this
class PlainSelectWidget(dfwidget.SelectWidget):
template = 'select_plain'
class CustomSelectWidget(dfwidget.SelectWidget):
"""
This widget is mostly for convenience. You can set extra kwargs for the
:meth:`serialize()` method, e.g.::
widget.set_template_values(foo='bar')
"""
def set_template_values(self, **kw):
if not hasattr(self, 'extra_template_values'):
self.extra_template_values = {}
self.extra_template_values.update(kw)
def get_template_values(self, field, cstruct, kw):
values = super().get_template_values(field, cstruct, kw)
if hasattr(self, 'extra_template_values'):
values.update(self.extra_template_values)
return values
class DynamicSelectWidget(CustomSelectWidget):
"""
This is a "normal" select widget, but instead of (or in addition to) its
values being set when constructed, they must be assigned dynamically in
real-time, e.g. based on other user selections.
Really all this widget "does" is render some Vue.js-compatible HTML, but
the page which contains the widget is ultimately responsible for wiring up
the logic for things to work right.
"""
template = 'select_dynamic'
class JQuerySelectWidget(dfwidget.SelectWidget):
template = 'select_jquery'
class PlainDateWidget(dfwidget.DateInputWidget):
template = 'date_plain'
class JQueryDateWidget(dfwidget.DateInputWidget):
"""
Uses the jQuery datepicker UI widget, instead of whatever it is deform uses
by default.
"""
template = 'date_jquery'
type_name = 'text'
requirements = None
default_options = (
('changeMonth', True),
('changeYear', True),
('dateFormat', 'yy-mm-dd'),
)
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
readonly = kw.get('readonly', self.readonly)
template = readonly and self.readonly_template or self.template
options = dict(
kw.get('options') or self.options or self.default_options
)
options.update(kw.get('extra_options', {}))
kw.setdefault('options_json', json.dumps(options))
kw.setdefault('selected_callback', None)
values = self.get_template_values(field, cstruct, kw)
return field.renderer(template, **values)
class JQueryTimeWidget(dfwidget.TimeInputWidget):
"""
Uses the jQuery datepicker UI widget, instead of whatever it is deform uses
by default.
"""
template = 'time_jquery'
type_name = 'text'
requirements = None
default_options = (
('showPeriod', True),
)
class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
"""
Custom widget for rattail UTC datetimes
"""
template = 'datetime_falafel'
new_pattern = re.compile(r'^\d\d?:\d\d:\d\d [AP]M$')
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get('readonly', self.readonly)
values = self.get_template_values(field, cstruct, kw)
template = self.readonly_template if readonly else self.template
return field.renderer(template, **values)
def deserialize(self, field, pstruct):
""" """
if pstruct == '':
return colander.null
# nb. we now allow '4:20:00 PM' on the widget side, but the
# true node needs it to be '16:20:00' instead
if self.new_pattern.match(pstruct['time']):
time = datetime.datetime.strptime(pstruct['time'], '%I:%M:%S %p')
pstruct['time'] = time.strftime('%H:%M:%S')
return pstruct
class FalafelTimeWidget(dfwidget.TimeInputWidget):
"""
Custom widget for simple time fields
"""
template = 'time_falafel'
def deserialize(self, field, pstruct):
""" """
if pstruct == '':
return colander.null
return pstruct
class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
"""
Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
by default.
"""
template = 'autocomplete_jquery'
requirements = None
field_display = ""
assigned_label = None
service_url = None
cleared_callback = None
selected_callback = None
input_callback = None
new_label_callback = None
ref = None
default_options = (
('autoFocus', True),
)
options = None
def serialize(self, field, cstruct, **kw):
""" """
if 'delay' in kw or getattr(self, 'delay', None):
raise ValueError(
'AutocompleteWidget does not support *delay* parameter '
'any longer.'
)
if cstruct in (colander.null, None):
cstruct = ''
self.values = self.values or []
readonly = kw.get('readonly', self.readonly)
options = dict(
kw.get('options') or self.options or self.default_options
)
options['source'] = self.service_url
kw['options'] = json.dumps(options)
kw['field_display'] = self.field_display
kw['cleared_callback'] = self.cleared_callback
kw['assigned_label'] = self.assigned_label
kw['input_callback'] = self.input_callback
kw['new_label_callback'] = self.new_label_callback
kw['ref'] = self.ref
kw.setdefault('selected_callback', self.selected_callback)
tmpl_values = self.get_template_values(field, cstruct, kw)
template = readonly and self.readonly_template or self.template
return field.renderer(template, **tmpl_values)
class FileUploadWidget(dfwidget.FileUploadWidget):
"""
Widget to handle file upload. Must override to add ``use_oruga``
to field template context.
"""
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
def get_template_values(self, field, cstruct, kw):
values = super().get_template_values(field, cstruct, kw)
if self.request:
values['use_oruga'] = self.request.use_oruga
return values
class MultiFileUploadWidget(dfwidget.FileUploadWidget):
"""
Widget to handle multiple (arbitrary number) of file uploads.
"""
template = 'multi_file_upload'
requirements = ()
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = []
if cstruct:
for fileinfo in cstruct:
uid = fileinfo['uid']
if uid not in self.tmpstore:
self.tmpstore[uid] = fileinfo
readonly = kw.get("readonly", self.readonly)
template = readonly and self.readonly_template or self.template
values = self.get_template_values(field, cstruct, kw)
return field.renderer(template, **values)
def deserialize(self, field, pstruct):
""" """
if pstruct is colander.null:
return colander.null
# TODO: why is this a thing? pstruct == [b'']
if len(pstruct) == 1 and pstruct[0] == b'':
return colander.null
files_data = []
for upload in pstruct:
data = self.deserialize_upload(upload)
if data:
files_data.append(data)
if not files_data:
return colander.null
return files_data
def deserialize_upload(self, upload):
""" """
# nb. this logic was copied from parent class and adapted
# to allow for multiple files. needs some more love.
uid = None # TODO?
if hasattr(upload, "file"):
# the upload control had a file selected
data = dfwidget.filedict()
data["fp"] = upload.file
filename = upload.filename
# sanitize IE whole-path filenames
filename = filename[filename.rfind("\\") + 1 :].strip()
data["filename"] = filename
data["mimetype"] = upload.type
data["size"] = upload.length
if uid is None:
# no previous file exists
while 1:
uid = self.random_id()
if self.tmpstore.get(uid) is None:
data["uid"] = uid
self.tmpstore[uid] = data
preview_url = self.tmpstore.preview_url(uid)
self.tmpstore[uid]["preview_url"] = preview_url
break
else:
# a previous file exists
data["uid"] = uid
self.tmpstore[uid] = data
preview_url = self.tmpstore.preview_url(uid)
self.tmpstore[uid]["preview_url"] = preview_url
else:
# the upload control had no file selected
if uid is None:
# no previous file exists
return colander.null
else:
# a previous file should exist
data = self.tmpstore.get(uid)
# but if it doesn't, don't blow up
if data is None:
return colander.null
return data
def make_customer_widget(request, **kwargs):
"""
Make a customer widget; will be either autocomplete or dropdown
depending on config.
"""
# use autocomplete widget by default
factory = CustomerAutocompleteWidget
# caller may request dropdown widget
if kwargs.pop('dropdown', False):
factory = CustomerDropdownWidget
else: # or, config may say to use dropdown
if request.rattail_config.getbool(
'rattail', 'customers.choice_uses_dropdown',
default=False):
factory = CustomerDropdownWidget
# instantiate whichever
return factory(request, **kwargs)
class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
"""
Autocomplete widget for a
:class:`~rattail:rattail.db.model.customers.Customer` reference
field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
app = self.request.rattail_config.get_app()
model = app.model
# must figure out URL providing autocomplete service
if 'service_url' not in kwargs:
# caller can just pass 'url' instead of 'service_url'
if 'url' in kwargs:
self.service_url = kwargs['url']
else: # use default url
self.service_url = self.request.route_url('customers.autocomplete')
# TODO
if 'input_callback' not in kwargs:
if 'input_handler' in kwargs:
self.input_callback = input_handler
def serialize(self, field, cstruct, **kw):
""" """
# fetch customer to provide button label, if we have a value
if cstruct:
app = self.request.rattail_config.get_app()
model = app.model
customer = Session.get(model.Customer, cstruct)
if customer:
self.field_display = str(customer)
return super().serialize(
field, cstruct, **kw)
class CustomerDropdownWidget(dfwidget.SelectWidget):
"""
Dropdown widget for a
:class:`~rattail:rattail.db.model.customers.Customer` reference
field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
app = self.request.rattail_config.get_app()
# must figure out dropdown values, if they weren't given
if 'values' not in kwargs:
# use what caller gave us, if they did
if 'customers' in kwargs:
customers = kwargs['customers']
if callable(customers):
customers = customers()
else: # default customer list
customers = app.get_clientele_handler()\
.get_all_customers(Session())
# convert customer list to option values
self.values = [(c.uuid, c.name)
for c in customers]
class DepartmentWidget(dfwidget.SelectWidget):
"""
Custom select widget for a Department reference field.
Constructor accepts the normal ``values`` kwarg but if not
provided then the widget will fetch department list from Rattail
DB.
Constructor also accepts ``required`` kwarg, which defaults to
true unless specified.
"""
def __init__(self, request, **kwargs):
if 'values' not in kwargs:
app = request.rattail_config.get_app()
model = app.model
departments = Session.query(model.Department)\
.order_by(model.Department.number)
values = [(dept.uuid, str(dept))
for dept in departments]
if not kwargs.pop('required', True):
values.insert(0, ('', "(none)"))
kwargs['values'] = values
super().__init__(**kwargs)
def make_vendor_widget(request, **kwargs):
"""
Make a vendor widget; will be either autocomplete or dropdown
depending on config.
"""
# use autocomplete widget by default
factory = VendorAutocompleteWidget
# caller may request dropdown widget
if kwargs.pop('dropdown', False):
factory = VendorDropdownWidget
else: # or, config may say to use dropdown
app = request.rattail_config.get_app()
vendor_handler = app.get_vendor_handler()
if vendor_handler.choice_uses_dropdown():
factory = VendorDropdownWidget
# instantiate whichever
return factory(request, **kwargs)
class VendorAutocompleteWidget(JQueryAutocompleteWidget):
"""
Autocomplete widget for a Vendor reference field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
app = self.request.rattail_config.get_app()
model = app.model
# must figure out URL providing autocomplete service
if 'service_url' not in kwargs:
# caller can just pass 'url' instead of 'service_url'
if 'url' in kwargs:
self.service_url = kwargs['url']
else: # use default url
self.service_url = self.request.route_url('vendors.autocomplete')
# # TODO
# if 'input_callback' not in kwargs:
# if 'input_handler' in kwargs:
# self.input_callback = input_handler
def serialize(self, field, cstruct, **kw):
""" """
# fetch vendor to provide button label, if we have a value
if cstruct:
app = self.request.rattail_config.get_app()
model = app.model
vendor = Session.get(model.Vendor, cstruct)
if vendor:
self.field_display = str(vendor)
return super().serialize(
field, cstruct, **kw)
class VendorDropdownWidget(dfwidget.SelectWidget):
"""
Dropdown widget for a Vendor reference field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
# must figure out dropdown values, if they weren't given
if 'values' not in kwargs:
# use what caller gave us, if they did
if 'vendors' in kwargs:
vendors = kwargs['vendors']
if callable(vendors):
vendors = vendors()
else: # default vendor list
app = self.request.rattail_config.get_app()
model = app.model
vendors = Session.query(model.Vendor)\
.order_by(model.Vendor.name)\
.all()
# convert vendor list to option values
self.values = [(c.uuid, c.name)
for c in vendors]

Some files were not shown because too many files have changed in this diff Show more