Compare commits

..

No commits in common. "master" and "v0.7.16" have entirely different histories.

501 changed files with 20229 additions and 78569 deletions

3
.gitignore vendored
View file

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

View file

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

3060
CHANGES.rst Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,6 @@ include *.txt
include *.rst include *.rst
include *.py include *.py
include tailbone/static/robots.txt
recursive-include tailbone/static *.js recursive-include tailbone/static *.js
recursive-include tailbone/static *.css recursive-include tailbone/static *.css
recursive-include tailbone/static *.png recursive-include tailbone/static *.png
@ -11,8 +10,6 @@ recursive-include tailbone/static *.jpg
recursive-include tailbone/static *.gif recursive-include tailbone/static *.gif
recursive-include tailbone/static *.ico recursive-include tailbone/static *.ico
recursive-include tailbone/static/files *
recursive-include tailbone/templates *.mako recursive-include tailbone/templates *.mako
recursive-include tailbone/templates *.pt recursive-include tailbone/templates *.pt
recursive-include tailbone/reports *.mako 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 Tailbone is an extensible web application based on Rattail. It provides a
"back-office network environment" (BONE) for use in managing retail data. "back-office network environment" (BONE) for use in managing retail data.
Please see Rattail's [home page](http://rattailproject.org/) for more Please see Rattail's `home page`_ for more information.
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,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.progress``
=====================
.. automodule:: tailbone.progress
:members:

View file

@ -3,4 +3,5 @@
======================== ========================
.. automodule:: tailbone.subscribers .. 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,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

@ -81,12 +81,6 @@ override when defining your subclass.
override this for certain views, if so that should be done within override this for certain views, if so that should be done within
:meth:`get_help_url()`. :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 Methods to Override
------------------- -------------------
@ -94,8 +88,6 @@ Methods to Override
The following is a list of methods which you can override when defining your The following is a list of methods which you can override when defining your
subclass. subclass.
.. automethod:: MasterView.editable_instance
.. .. automethod:: MasterView.get_settings .. .. automethod:: MasterView.get_settings
.. automethod:: MasterView.get_csv_fields .. automethod:: MasterView.get_csv_fields
@ -103,24 +95,3 @@ subclass.
.. automethod:: MasterView.get_csv_row .. automethod:: MasterView.get_csv_row
.. automethod:: MasterView.get_help_url .. 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

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,21 +1,38 @@
# Configuration file for the Sphinx documentation builder. # -*- coding: utf-8; -*-
# #
# For the full list of built-in configuration values, see the documentation: # Tailbone documentation build configuration file, created by
# https://www.sphinx-doc.org/en/master/usage/configuration.html # sphinx-quickstart on Sat Feb 15 23:15:27 2014.
#
# This file is exec()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# -- Project information ----------------------------------------------------- import sys
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import os
from importlib.metadata import version as get_version import sphinx_rtd_theme
project = 'Tailbone' exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read())
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 = [ extensions = [
'sphinx.ext.autodoc', 'sphinx.ext.autodoc',
'sphinx.ext.todo', 'sphinx.ext.todo',
@ -23,30 +40,237 @@ extensions = [
'sphinx.ext.viewcode', 'sphinx.ext.viewcode',
] ]
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = { intersphinx_mapping = {
'rattail': ('https://docs.wuttaproject.org/rattail/', None),
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
} }
# allow todo entries to show up # Add any paths that contain templates here, relative to this directory.
todo_include_todos = True templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'Tailbone'
copyright = u'2010 - 2018, Lance Edgar'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
# version = '0.3'
version = '.'.join(__version__.split('.')[:2])
# The full version, including alpha/beta/rc tags.
release = __version__
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output ----------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'furo' # The theme to use for HTML and HTML Help pages. See the documentation for
html_static_path = ['_static'] # a list of builtin themes.
# html_theme = 'classic'
html_theme = 'sphinx_rtd_theme'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
# The name for this set of Sphinx documents. If None, it defaults to
# "<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 # The name of an image file (relative to this directory) to place at the top
# of the sidebar. # of the sidebar.
#html_logo = None #html_logo = None
#html_logo = 'images/rattail_avatar.png' 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. # 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

@ -42,32 +42,12 @@ Package API:
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
api/api/batch/core
api/api/batch/ordering
api/db
api/diffs
api/forms api/forms
api/forms.widgets
api/grids api/grids
api/grids.core
api/progress
api/subscribers api/subscribers
api/util
api/views/batch api/views/batch
api/views/batch.vendorcatalog
api/views/core
api/views/master api/views/master
api/views/members api/views/vendors.catalogs
api/views/purchasing.batch
api/views/purchasing.ordering
Changelog:
.. toctree::
:maxdepth: 1
changelog
Documentation To-Do Documentation To-Do

View file

@ -117,6 +117,7 @@ of course supply the web app layer.
│   │   │   └── foobatch/ │   │   │   └── foobatch/
│   │   ├── customers/ │   │   ├── customers/
│   │   ├── menu.mako │   │   ├── menu.mako
│   │   ├── mobile/
│   │   └── products/ │   │   └── products/
│   └── views/ │   └── views/
│   ├── __init__.py │   ├── __init__.py

View file

@ -1,103 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "Tailbone"
version = "0.22.7"
description = "Backoffice Web Application for Rattail"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
license = {text = "GNU GPL v3+"}
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Framework :: Pyramid",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Office/Business",
"Topic :: Software Development :: Libraries :: Python Modules",
]
requires-python = ">= 3.8"
dependencies = [
"asgiref",
"colander",
"ColanderAlchemy",
"cornice",
"cornice-swagger",
"deform",
"humanize",
"Mako",
"markdown",
"openpyxl",
"paginate",
"paginate_sqlalchemy",
"passlib",
"Pillow",
"pyramid>=2",
"pyramid_beaker",
"pyramid_deform",
"pyramid_exclog",
"pyramid_fanstatic",
"pyramid_mako",
"pyramid_retry",
"pyramid_tm",
"rattail[db,bouncer]>=0.20.1",
"sa-filters",
"simplejson",
"transaction",
"waitress",
"WebHelpers2",
"WuttaWeb>=0.21.0",
"zope.sqlalchemy>=1.5",
]
[project.optional-dependencies]
docs = ["Sphinx", "furo"]
tests = ["coverage", "mock", "pytest", "pytest-cov"]
[project.entry-points."paste.app_factory"]
main = "tailbone.app:main"
webapi = "tailbone.webapi:main"
[project.entry-points."rattail.cleaners"]
beaker = "tailbone.cleanup:BeakerCleaner"
[project.entry-points."rattail.config.extensions"]
tailbone = "tailbone.config:ConfigExtension"
[project.urls]
Homepage = "https://rattailproject.org"
Repository = "https://forgejo.wuttaproject.org/rattail/tailbone"
Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues"
Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md"
[tool.commitizen]
version_provider = "pep621"
tag_format = "v$version"
update_changelog_on_bump = true
[tool.nosetests]
nocapture = 1
cover-package = "tailbone"
cover-erase = 1
cover-html = 1
cover-html-dir = "htmlcov"

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

171
setup.py Normal file
View file

@ -0,0 +1,171 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Setup script for Tailbone
"""
from __future__ import unicode_literals, absolute_import
import os.path
from setuptools import setup, find_packages
here = os.path.abspath(os.path.dirname(__file__))
exec(open(os.path.join(here, 'tailbone', '_version.py')).read())
README = open(os.path.join(here, 'README.rst')).read()
requires = [
#
# Version numbers within comments below have specific meanings.
# Basically the 'low' value is a "soft low," and 'high' a "soft high."
# In other words:
#
# If either a 'low' or 'high' value exists, the primary point to be
# made about the value is that it represents the most current (stable)
# version available for the package (assuming typical public access
# methods) whenever this project was started and/or documented.
# Therefore:
#
# If a 'low' version is present, you should know that attempts to use
# versions of the package significantly older than the 'low' version
# may not yield happy results. (A "hard" high limit may or may not be
# indicated by a true version requirement.)
#
# Similarly, if a 'high' version is present, and especially if this
# project has laid dormant for a while, you may need to refactor a bit
# when attempting to support a more recent version of the package. (A
# "hard" low limit should be indicated by a true version requirement
# when a 'high' version is present.)
#
# In any case, developers and other users are encouraged to play
# outside the lines with regard to these soft limits. If bugs are
# encountered then they should be filed as such.
#
# package # low high
# TODO: Pyramid 1.9 looks like it breaks us..? playing it safe for now..
'pyramid<1.9', # 1.3b2 1.8.3
# apparently 2.0 removes the retry support, in which case we then need
# pyramid_retry .. but that requires pyramid 1.9 ...
'pyramid_tm<2.0', # 0.3 1.1.1
# TODO: why do we need to cap this? breaks tailbone.db zope stuff somehow
'zope.sqlalchemy<1.0', # 0.7 0.7.7
'ColanderAlchemy', # 0.3.3
'deform', # 2.0.4
'humanize', # 0.5.1
'Mako', # 0.6.2
'openpyxl', # 2.4.7
'paginate', # 0.5.6
'paginate_sqlalchemy', # 0.2.0
'passlib', # 1.7.1
'pyramid_beaker>=0.6', # 0.6.1
'pyramid_deform', # 0.2
'pyramid_exclog', # 0.6
'pyramid_mako', # 1.0.2
'rattail[db,bouncer]', # 0.5.0
'six', # 1.10.0
'transaction', # 1.2.0
'waitress', # 0.8.1
'WebHelpers2', # 2.0
'webhelpers2_grid', # 0.1
'WTForms', # 2.1
]
extras = {
'docs': [
#
# package # low high
'Sphinx', # 1.2
'sphinx-rtd-theme', # 0.2.4
],
'tests': [
#
# package # low high
'coverage', # 3.6
'fixture', # 1.5
'mock', # 1.0.1
'nose', # 1.3.0
],
}
setup(
name = "Tailbone",
version = __version__,
author = "Lance Edgar",
author_email = "lance@edbob.org",
url = "http://rattailproject.org/",
license = "GNU GPL v3",
description = "Backoffice Web Application for Rattail",
long_description = README,
classifiers = [
'Development Status :: 4 - Beta',
'Environment :: Web Environment',
'Framework :: Pyramid',
'Intended Audience :: Developers',
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
'Natural Language :: English',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Office/Business',
'Topic :: Software Development :: Libraries :: Python Modules',
],
install_requires = requires,
extras_require = extras,
tests_require = ['Tailbone[tests]'],
test_suite = 'nose.collector',
packages = find_packages(exclude=['tests.*', 'tests']),
include_package_data = True,
zip_safe = False,
entry_points = {
'paste.app_factory': [
'main = tailbone.app:main',
],
'rattail.config.extensions': [
'tailbone = tailbone.config:ConfigExtension',
],
'pyramid.scaffold': [
'rattail = tailbone.scaffolds:RattailTemplate',
],
},
)

View file

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

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,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

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,23 +24,24 @@
Application Entry Point Application Entry Point
""" """
from __future__ import unicode_literals, absolute_import
import os import os
import warnings
from sqlalchemy.orm import sessionmaker, scoped_session import sqlalchemy as sa
from wuttjamaican.util import parse_list
import rattail.db
from rattail.config import make_config from rattail.config import make_config
from rattail.exceptions import ConfigurationError from rattail.exceptions import ConfigurationError
from rattail.db.config import get_engines, configure_versioning
from rattail.db.types import GPCType
from pyramid.config import Configurator from pyramid.config import Configurator
from zope.sqlalchemy import register from pyramid.authentication import SessionAuthenticationPolicy
import tailbone.db import tailbone.db
from tailbone.auth import TailboneSecurityPolicy from tailbone.auth import TailboneAuthorizationPolicy
from tailbone.config import csrf_token_name, csrf_header_name
from tailbone.util import get_effective_theme, get_theme_template_path
from tailbone.providers import get_all_providers
def make_rattail_config(settings): def make_rattail_config(settings):
@ -54,48 +55,45 @@ def make_rattail_config(settings):
# available for web requests later # available for web requests later
path = settings.get('rattail.config') path = settings.get('rattail.config')
if not path or not os.path.exists(path): if not path or not os.path.exists(path):
raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config " path = settings.get('edbob.config')
"to the path of your config file. Lame, but necessary.") 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) rattail_config = make_config(path)
settings['rattail_config'] = rattail_config settings['rattail_config'] = rattail_config
rattail_config.configure_logging()
# nb. this is for compaibility with wuttaweb rattail_engines = settings.get('rattail_engines')
settings['wutta_config'] = rattail_config if not rattail_engines:
# must import all sqlalchemy models before things get rolling, # Load all Rattail database engines from config, and store in settings
# otherwise can have errors about continuum TransactionMeta class # dict. This is necessary e.g. in the case of a host server, to have
# not yet mapped, when relevant pages are first requested... # access to its subordinate store servers.
# cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models rattail_engines = get_engines(rattail_config)
# hat tip to https://stackoverflow.com/a/59241485 settings['rattail_engines'] = rattail_engines
if getattr(rattail_config, 'tempmon_engine', None):
from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
tempmon_session = TempmonSession()
tempmon_session.query(tempmon_model.Appliance).first()
tempmon_session.close()
# configure database sessions # Configure the database session classes. Note that most of the time we'll
if hasattr(rattail_config, 'appdb_engine'): # be using the Tailbone Session, but occasionally (e.g. within batch
tailbone.db.Session.configure(bind=rattail_config.appdb_engine) # processing threads) we want the Rattail Session. The reason is that
if hasattr(rattail_config, 'trainwreck_engine'): # during normal request processing, the Tailbone Session is preferable as
tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) # 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'): if hasattr(rattail_config, 'tempmon_engine'):
tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine) tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine)
if hasattr(rattail_config, 'trainwreck_engine'):
# maybe set "future" behavior for SQLAlchemy tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
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 # Make sure rattail config object uses our scoped session, to avoid
# unnecessary connections (and pooling limits). # unnecessary connections (and pooling limits).
rattail_config._session_factory = lambda: (tailbone.db.Session(), False) rattail_config._session_factory = lambda: (tailbone.db.Session(), False)
# Configure (or not) Continuum versioning.
configure_versioning(rattail_config)
return rattail_config return rattail_config
@ -105,12 +103,7 @@ def provide_postgresql_settings(settings):
this enables retrying transactions a second time, in an attempt to this enables retrying transactions a second time, in an attempt to
gracefully handle database restarts. gracefully handle database restarts.
""" """
try: settings.setdefault('tm.attempts', 2)
import pyramid_retry
except ImportError:
settings.setdefault('tm.attempts', 2)
else:
settings.setdefault('retry.attempts', 2)
class Root(dict): class Root(dict):
@ -128,197 +121,42 @@ def make_pyramid_config(settings, configure_csrf=True):
""" """
Make a Pyramid config object from the given settings. Make a Pyramid config object from the given settings.
""" """
rattail_config = settings['rattail_config']
config = settings.pop('pyramid_config', None) config = settings.pop('pyramid_config', None)
if config: if config:
config.set_root_factory(Root) config.set_root_factory(Root)
else: else:
# declare this web app of the "classic" variety
settings.setdefault('tailbone.classic', 'true')
# we want the new themes feature!
establish_theme(settings)
settings.setdefault('fanstatic.versioning', 'true')
settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
config = Configurator(settings=settings, root_factory=Root) 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 # configure user authorization / authentication
config.set_security_policy(TailboneSecurityPolicy()) config.set_authorization_policy(TailboneAuthorizationPolicy())
config.set_authentication_policy(SessionAuthenticationPolicy())
# maybe require CSRF token protection # always require CSRF token protection
if configure_csrf: if configure_csrf:
config.set_default_csrf_options(require_csrf=True, config.set_default_csrf_options(require_csrf=True, token='_csrf')
token=csrf_token_name(rattail_config),
header=csrf_header_name(rattail_config))
# Bring in some Pyramid goodies. # Bring in some Pyramid goodies.
config.include('tailbone.beaker') config.include('tailbone.beaker')
config.include('pyramid_deform') config.include('pyramid_deform')
config.include('pyramid_fanstatic')
config.include('pyramid_mako') config.include('pyramid_mako')
config.include('pyramid_tm') config.include('pyramid_tm')
# TODO: this may be a good idea some day, if wanting to leverage # Add some permissions magic.
# deform resources for component JS? cf. also base.mako template config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
# # override default script mapping for deform config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
# from deform import Field
# from deform.widget import ResourceRegistry, default_resources
# registry = ResourceRegistry(use_defaults=False)
# for key in default_resources:
# registry.set_js_resources(key, None, {'js': []})
# Field.set_default_resource_registry(registry)
# bring in the pyramid_retry logic, if available # TODO: This can finally be removed once all CRUD/index views have been
# TODO: pretty soon we can require this package, hopefully.. # converted to use the new master view etc.
try: for label, perms in settings.get('edbob.permissions', []):
import pyramid_retry groupkey = label.lower().replace(' ', '_')
except ImportError: config.add_tailbone_permission_group(groupkey, label)
pass for key, label in perms:
else: config.add_tailbone_permission(groupkey, key, label)
config.include('pyramid_retry')
# fetch all tailbone providers
providers = get_all_providers(rattail_config)
for provider in providers.values():
# configure DB sessions associated with transaction manager
provider.configure_db_sessions(rattail_config, config)
# add any static includes
includes = provider.get_static_includes()
if includes:
for spec in includes:
config.include(spec)
# add some permissions magic
config.add_directive('add_wutta_permission_group',
'wuttaweb.auth.add_permission_group')
config.add_directive('add_wutta_permission',
'wuttaweb.auth.add_permission')
# TODO: deprecate / remove these
config.add_directive('add_tailbone_permission_group',
'wuttaweb.auth.add_permission_group')
config.add_directive('add_tailbone_permission',
'wuttaweb.auth.add_permission')
# and some similar magic for certain master views
config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page')
config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page')
config.add_directive('add_tailbone_model_view', 'tailbone.app.add_model_view')
config.add_directive('add_tailbone_view_supplement', 'tailbone.app.add_view_supplement')
config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket')
return config 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): def configure_postgresql(pyramid_config):
""" """
Add some PostgreSQL-specific tweaks to the final app config. Specifically, Add some PostgreSQL-specific tweaks to the final app config. Specifically,
@ -332,8 +170,7 @@ def main(global_config, **settings):
""" """
This function returns a Pyramid WSGI application. This function returns a Pyramid WSGI application.
""" """
settings.setdefault('mako.directories', ['tailbone:templates', settings.setdefault('mako.directories', ['tailbone:templates'])
'wuttaweb:templates'])
rattail_config = make_rattail_config(settings) rattail_config = make_rattail_config(settings)
pyramid_config = make_pyramid_config(settings) pyramid_config = make_pyramid_config(settings)
pyramid_config.include('tailbone') 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

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,31 +24,32 @@
Authentication & Authorization Authentication & Authorization
""" """
from __future__ import unicode_literals, absolute_import
import logging import logging
import re
from wuttjamaican.util import UNSPECIFIED from rattail import enum
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, forget, Everyone, Authenticated
from wuttaweb.auth import WuttaSecurityPolicy
from tailbone.db import Session from tailbone.db import Session
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def login_user(request, user, timeout=UNSPECIFIED): def login_user(request, user, timeout=NOTSET):
""" """
Perform the steps necessary to login the given user. Note that this Perform the steps necessary to login the given user. Note that this
returns a ``headers`` dict which you should pass to the redirect. returns a ``headers`` dict which you should pass to the redirect.
""" """
config = request.rattail_config user.record_event(enum.USER_EVENT_LOGIN)
app = config.get_app()
user.record_event(app.enum.USER_EVENT_LOGIN)
headers = remember(request, user.uuid) headers = remember(request, user.uuid)
if timeout is UNSPECIFIED: if timeout is NOTSET:
timeout = session_timeout_for_user(config, user) timeout = session_timeout_for_user(user)
log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
set_session_timeout(request, timeout) set_session_timeout(request, timeout)
return headers return headers
@ -59,28 +60,24 @@ def logout_user(request):
Perform the logout action for the given request. Note that this returns a Perform the logout action for the given request. Note that this returns a
``headers`` dict which you should pass to the redirect. ``headers`` dict which you should pass to the redirect.
""" """
app = request.rattail_config.get_app()
user = request.user user = request.user
if user: if user:
user.record_event(app.enum.USER_EVENT_LOGOUT) user.record_event(enum.USER_EVENT_LOGOUT)
request.session.delete() request.session.delete()
request.session.invalidate() request.session.invalidate()
headers = forget(request) headers = forget(request)
return headers return headers
def session_timeout_for_user(config, user): def session_timeout_for_user(user):
""" """
Returns the "max" session timeout for the user, according to roles Returns the "max" session timeout for the user, according to roles
""" """
app = config.get_app() from rattail.db.auth import authenticated_role
auth = app.get_auth_handler()
authenticated = auth.get_role_authenticated(Session()) roles = user.roles + [authenticated_role(Session())]
roles = user.roles + [authenticated]
timeouts = [role.session_timeout for role in roles timeouts = [role.session_timeout for role in roles
if role.session_timeout is not None] if role.session_timeout is not None]
if timeouts and 0 not in timeouts: if timeouts and 0 not in timeouts:
return max(timeouts) return max(timeouts)
@ -92,42 +89,53 @@ def set_session_timeout(request, timeout):
request.session['_timeout'] = timeout or None request.session['_timeout'] = timeout or None
class TailboneSecurityPolicy(WuttaSecurityPolicy): @implementer(IAuthorizationPolicy)
class TailboneAuthorizationPolicy(object):
def __init__(self, db_session=None, api_mode=False, **kwargs): def permits(self, context, principals, permission):
kwargs['db_session'] = db_session or Session() from rattail.db import model
super().__init__(**kwargs) from rattail.db.auth import has_permission
self.api_mode = api_mode
def load_identity(self, request): for userid in principals:
config = request.registry.settings.get('rattail_config') if userid not in (Everyone, Authenticated):
app = config.get_app() if context.request.user and context.request.user.uuid == userid:
user = None 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
if self.api_mode: def principals_allowed_by_permission(self, context, permission):
raise NotImplementedError
# determine/load user from header token if present
credentials = request.headers.get('Authorization')
if credentials:
match = re.match(r'^Bearer (\S+)$', credentials)
if match:
token = match.group(1)
auth = app.get_auth_handler()
user = auth.authenticate_user_token(self.db_session, token)
if not user: def add_permission_group(config, key, label=None, overwrite=True):
"""
Add a permission group to the app configuration.
"""
def action():
perms = config.get_settings().get('tailbone_permissions', {})
if key not in perms or overwrite:
group = perms.setdefault(key, {'key': key})
group['label'] = label or prettify(key)
config.add_settings({'tailbone_permissions': perms})
config.action(None, action)
# fetch user uuid from current session
uuid = self.session_helper.authenticated_userid(request)
if not uuid:
return
# fetch user object from db def add_permission(config, groupkey, key, label=None):
model = app.model """
user = self.db_session.get(model.User, uuid) Add a permission to the app configuration.
if not user: """
return def action():
perms = config.get_settings().get('tailbone_permissions', {})
# this user is responsible for data changes in current request group = perms.setdefault(groupkey, {'key': groupkey})
self.db_session.set_continuum_user(user) group.setdefault('label', prettify(groupkey))
return user 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,8 +1,8 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8 -*-
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -27,12 +27,10 @@ Note that most of the code for this module was copied from the beaker and
pyramid_beaker projects. pyramid_beaker projects.
""" """
from __future__ import unicode_literals, absolute_import
import time import time
from pkg_resources import parse_version
from rattail.util import get_pkg_version
import beaker
from beaker.session import Session from beaker.session import Session
from beaker.util import coerce_session_params from beaker.util import coerce_session_params
from pyramid.settings import asbool from pyramid.settings import asbool
@ -47,10 +45,6 @@ class TailboneSession(Session):
def load(self): def load(self):
"Loads the data from this session from persistent storage" "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, self.namespace = self.namespace_class(self.id,
data_dir=self.data_dir, data_dir=self.data_dir,
digest_filenames=False, digest_filenames=False,
@ -66,12 +60,8 @@ class TailboneSession(Session):
try: try:
session_data = self.namespace['session'] session_data = self.namespace['session']
if old_beaker: if (session_data is not None and self.encrypt_key):
if (session_data is not None and self.encrypt_key): session_data = self._decrypt_data(session_data)
session_data = self._decrypt_data(session_data)
else: # beaker >= 1.12
if session_data is not None:
session_data = self._decrypt_data(session_data)
# Memcached always returns a key, its None when its not # Memcached always returns a key, its None when its not
# present # present
@ -100,7 +90,6 @@ class TailboneSession(Session):
# for this module entirely... # for this module entirely...
timeout = session_data.get('_timeout', self.timeout) timeout = session_data.get('_timeout', self.timeout)
if timeout is not None and \ if timeout is not None and \
'_accessed_time' in session_data and \
now - session_data['_accessed_time'] > timeout: now - session_data['_accessed_time'] > timeout:
timed_out = True timed_out = True
else: else:
@ -114,6 +103,9 @@ class TailboneSession(Session):
# Update the current _accessed_time # Update the current _accessed_time
session_data['_accessed_time'] = now 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.update(session_data)
self.accessed_dict = session_data.copy() self.accessed_dict = session_data.copy()
finally: 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,8 +1,8 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8 -*-
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,16 +24,15 @@
Rattail config extension for Tailbone Rattail config extension for Tailbone
""" """
import warnings from __future__ import unicode_literals, absolute_import
from wuttjamaican.conf import WuttaConfigExtension
from rattail.config import ConfigExtension as BaseExtension
from rattail.db.config import configure_session from rattail.db.config import configure_session
from tailbone.db import Session from tailbone.db import Session
class ConfigExtension(WuttaConfigExtension): class ConfigExtension(BaseExtension):
""" """
Rattail config extension for Tailbone. Does the following: Rattail config extension for Tailbone. Does the following:
@ -48,31 +47,3 @@ class ConfigExtension(WuttaConfigExtension):
def configure(self, config): def configure(self, config):
Session.configure(rattail_config=config) Session.configure(rattail_config=config)
configure_session(config, Session) 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,8 +1,8 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8 -*-
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -21,9 +21,11 @@
# #
################################################################################ ################################################################################
""" """
Database sessions etc. Database Stuff
""" """
from __future__ import unicode_literals, absolute_import
import sqlalchemy as sa import sqlalchemy as sa
from zope.sqlalchemy import datamanager from zope.sqlalchemy import datamanager
import sqlalchemy_continuum as continuum import sqlalchemy_continuum as continuum
@ -33,37 +35,24 @@ from rattail.db import SessionBase
from rattail.db.continuum import versioning_manager 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, expire_on_commit=False))
# not necessarily used, but here if you need it # not necessarily used, but here if you need it
TempmonSession = scoped_session(sessionmaker()) TempmonSession = scoped_session(sessionmaker())
TrainwreckSession = scoped_session(sessionmaker()) TrainwreckSession = scoped_session(sessionmaker())
# empty dict for now, this must populated on app startup (if needed)
ExtraTrainwreckSessions = {}
class TailboneSessionDataManager(datamanager.SessionDataManager): 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. One phase variant.
.. note:: .. note::
This class appears to be necessary in order for the Continuum
This class appears to be necessary in order for the integration to work alongside the Zope transaction integration.
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It subclasses
``zope.sqlalchemy.datamanager.SessionDataManager`` but injects
some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and
is sort of monkey-patched into the mix.
""" """
def tpc_vote(self, trans): def tpc_vote(self, trans):
""" """
# for a one phase data manager commit last in tpc_vote # 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 if self.tx is not None: # there may have been no work to do
@ -75,42 +64,25 @@ class TailboneSessionDataManager(datamanager.SessionDataManager):
self._finish('committed') self._finish('committed')
def join_transaction( def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
session, """Join a session to a transaction using the appropriate datamanager.
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 It is safe to call this multiple times, if the session is already joined
joined then it just returns. then it just returns.
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY
STATUS_READONLY
If using the default initial status of STATUS_ACTIVE, you must If using the default initial status of STATUS_ACTIVE, you must ensure that
ensure that mark_changed(session) is called when data is written mark_changed(session) is called when data is written to the database.
to the database.
The ZopeTransactionExtesion SessionExtension can be used to ensure The ZopeTransactionExtesion SessionExtension can be used to ensure that this is
that this is called automatically after session write operations. called automatically after session write operations.
.. note:: .. note::
This function is copied from upstream, and tweaked so that our custom
This function appears to be necessary in order for the :class:`TailboneSessionDataManager` will be used.
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It overrides ``zope.sqlalchemy.datamanager.join_transaction()``
to ensure the custom :class:`TailboneSessionDataManager` is
used, and is sort of monkey-patched into the mix.
""" """
# the upstream internals of this function has changed a little over time. if datamanager._SESSION_STATE.get(id(session), None) is None:
# unfortunately for us, that means we must include each variant here.
if datamanager._SESSION_STATE.get(session, None) is None:
if session.twophase: if session.twophase:
DataManager = datamanager.TwoPhaseSessionDataManager DataManager = datamanager.TwoPhaseSessionDataManager
else: else:
@ -118,73 +90,43 @@ def join_transaction(
DataManager(session, initial_state, transaction_manager, keep_session=keep_session) DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
class ZopeTransactionEvents(datamanager.ZopeTransactionEvents): class ZopeTransactionExtension(datamanager.ZopeTransactionExtension):
""" """Record that a flush has occurred on a session's connection. This allows
Record that a flush has occurred on a session's connection. This the DataManager to rollback rather than commit on read only transactions.
allows the DataManager to rollback rather than commit on read only
transactions.
.. note:: .. note::
This class is copied from upstream, and tweaked so that our custom
This class appears to be necessary in order for the :func:`join_transaction()` will be used.
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It subclasses
``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but
overrides various methods to ensure the custom
:func:`join_transaction()` is called, and is sort of
monkey-patched into the mix.
""" """
def after_begin(self, session, transaction, connection): 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): def after_attach(self, session, instance):
""" """ join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session)
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def join_transaction(self, session):
""" """
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def register( def register(session, initial_state=datamanager.STATUS_ACTIVE,
session, transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
initial_state=datamanager.STATUS_ACTIVE, """Register ZopeTransaction listener events on the
transaction_manager=datamanager.zope_transaction.manager, given Session or Session factory/class.
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 This function requires at least SQLAlchemy 0.7 and makes use
the newer sqlalchemy.event package in order to register event of the newer sqlalchemy.event package in order to register event listeners
listeners on the given Session. on the given Session.
The session argument here may be a Session class or subclass, a The session argument here may be a Session class or subclass, a
sessionmaker or scoped_session instance, or a specific Session sessionmaker or scoped_session instance, or a specific Session instance.
instance. Event listening will be specific to the scope of the Event listening will be specific to the scope of the type of argument
type of argument passed, including specificity to its subclass as passed, including specificity to its subclass as well as its identity.
well as its identity.
.. note:: .. note::
This function is copied from upstream, and tweaked so that our custom
This function appears to be necessary in order for the :class:`ZopeTransactionExtension` will be used.
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to
ensure the custom :class:`ZopeTransactionEvents` is used.
""" """
from sqlalchemy import event from sqlalchemy import event
ext = ZopeTransactionEvents( ext = ZopeTransactionExtension(
initial_state=initial_state, initial_state=initial_state,
transaction_manager=transaction_manager, transaction_manager=transaction_manager,
keep_session=keep_session, keep_session=keep_session,
@ -197,9 +139,6 @@ def register(
event.listen(session, "after_bulk_delete", ext.after_bulk_delete) event.listen(session, "after_bulk_delete", ext.after_bulk_delete)
event.listen(session, "before_commit", ext.before_commit) 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(Session)
register(TempmonSession) register(TempmonSession)

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,8 +24,7 @@
Tools for displaying data diffs Tools for displaying data diffs
""" """
import sqlalchemy as sa from __future__ import unicode_literals, absolute_import
import sqlalchemy_continuum as continuum
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import HTML from webhelpers2.html import HTML
@ -34,43 +33,16 @@ from webhelpers2.html import HTML
class Diff(object): class Diff(object):
""" """
Core diff class. In sore need of documentation. 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, def __init__(self, old_data, new_data, columns=None, fields=None, render_field=None, render_value=None, monospace=False):
render_field=None, render_value=None, nature='dirty',
monospace=False, extra_row_attrs=None):
self.old_data = old_data self.old_data = old_data
self.new_data = new_data self.new_data = new_data
self.columns = columns or ["field name", "old value", "new value"] self.columns = columns or ["field name", "old value", "new value"]
self.fields = fields or self.make_fields() self.fields = fields or self.make_fields()
self.enums = enums or {}
self._render_field = render_field or self.render_field_default self._render_field = render_field or self.render_field_default
self.render_value = render_value or self.render_value_default self.render_value = render_value or self.render_value_default
self.nature = nature
self.monospace = monospace self.monospace = monospace
self.extra_row_attrs = extra_row_attrs
def make_fields(self): def make_fields(self):
return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower()) return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower())
@ -89,32 +61,6 @@ class Diff(object):
context['diff'] = self context['diff'] = self
return HTML.literal(render(template, context)) 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): def render_field(self, field):
return self._render_field(field, self) return self._render_field(field, self)
@ -131,161 +77,3 @@ class Diff(object):
def render_new_value(self, field): def render_new_value(self, field):
value = self.new_value(field) value = self.new_value(field)
return self.render_value(field, value) 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

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,7 +24,8 @@
Forms Library Forms Library
""" """
# nb. import widgets before types, b/c types may refer to widgets from __future__ import unicode_literals, absolute_import
from . import widgets
from . import types from . import types
from .core import Form, SimpleFileImport from . import widgets
from .core import Form

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

@ -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

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2018 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,9 +24,11 @@
Form Schema Types Form Schema Types
""" """
from __future__ import unicode_literals, absolute_import
import re import re
import datetime
import json import six
from rattail.db import model from rattail.db import model
from rattail.gpc import GPC from rattail.gpc import GPC
@ -34,7 +36,6 @@ from rattail.gpc import GPC
import colander import colander
from tailbone.db import Session from tailbone.db import Session
from tailbone.forms import widgets
class JQueryTime(colander.Time): class JQueryTime(colander.Time):
@ -54,94 +55,12 @@ class JQueryTime(colander.Time):
] ]
for fmt in formats: for fmt in formats:
try: try:
return datetime.datetime.strptime(cstruct, fmt).time() return colander.timeparse(cstruct, fmt)
except ValueError: except ValueError:
pass pass
# re-try first format, for "better" error message # re-try first format, for "better" error message
return datetime.datetime.strptime(cstruct, formats[0]).time() return colander.timeparse(cstruct, formats[0])
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): class GPCType(colander.SchemaType):
@ -152,7 +71,7 @@ class GPCType(colander.SchemaType):
def serialize(self, node, appstruct): def serialize(self, node, appstruct):
if appstruct is colander.null: if appstruct is colander.null:
return colander.null return colander.null
return str(appstruct) return six.text_type(appstruct)
def deserialize(self, node, cstruct): def deserialize(self, node, cstruct):
if not cstruct: if not cstruct:
@ -163,17 +82,7 @@ class GPCType(colander.SchemaType):
try: try:
return GPC(digits) return GPC(digits)
except Exception as err: except Exception as err:
raise colander.Invalid(node, str(err)) raise colander.Invalid(node, six.text_type(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): class ModelType(colander.SchemaType):
@ -201,12 +110,12 @@ class ModelType(colander.SchemaType):
def serialize(self, node, appstruct): def serialize(self, node, appstruct):
if appstruct is colander.null: if appstruct is colander.null:
return colander.null return colander.null
return str(appstruct) return six.text_type(appstruct)
def deserialize(self, node, cstruct): def deserialize(self, node, cstruct):
if not cstruct: if not cstruct:
return None return None
obj = self.session.get(self.model_class, cstruct) obj = self.session.query(self.model_class).get(cstruct)
if not obj: if not obj:
raise colander.Invalid(node, "{} not found".format(self.model_title)) raise colander.Invalid(node, "{} not found".format(self.model_title))
return obj return obj

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2018 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,24 +24,23 @@
Form Widgets Form Widgets
""" """
from __future__ import unicode_literals, absolute_import
import json import json
import datetime import datetime
import decimal
import re import six
import colander import colander
from deform import widget as dfwidget from deform import widget as dfwidget
from webhelpers2.html import tags, HTML from webhelpers2.html import tags, HTML
from tailbone.db import Session
class ReadonlyWidget(dfwidget.HiddenWidget): class ReadonlyWidget(dfwidget.HiddenWidget):
readonly = True readonly = True
def serialize(self, field, cstruct, **kw): def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None): if cstruct in (colander.null, None):
cstruct = '' cstruct = ''
# TODO: is this hacky? # TODO: is this hacky?
@ -56,150 +55,14 @@ class NumberInputWidget(dfwidget.TextInputWidget):
autocomplete = 'off' 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): class PlainSelectWidget(dfwidget.SelectWidget):
template = 'select_plain' 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): class JQuerySelectWidget(dfwidget.SelectWidget):
template = 'select_jquery' template = 'select_jquery'
class PlainDateWidget(dfwidget.DateInputWidget):
template = 'date_plain'
class JQueryDateWidget(dfwidget.DateInputWidget): class JQueryDateWidget(dfwidget.DateInputWidget):
""" """
Uses the jQuery datepicker UI widget, instead of whatever it is deform uses Uses the jQuery datepicker UI widget, instead of whatever it is deform uses
@ -216,7 +79,6 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
) )
def serialize(self, field, cstruct, **kw): def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None): if cstruct in (colander.null, None):
cstruct = '' cstruct = ''
readonly = kw.get('readonly', self.readonly) readonly = kw.get('readonly', self.readonly)
@ -244,48 +106,6 @@ class JQueryTimeWidget(dfwidget.TimeInputWidget):
) )
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): class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
""" """
Uses the jQuery autocomplete plugin, instead of whatever it is deform uses Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
@ -294,13 +114,9 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
template = 'autocomplete_jquery' template = 'autocomplete_jquery'
requirements = None requirements = None
field_display = "" field_display = ""
assigned_label = None
service_url = None service_url = None
cleared_callback = None cleared_callback = None
selected_callback = None selected_callback = None
input_callback = None
new_label_callback = None
ref = None
default_options = ( default_options = (
('autoFocus', True), ('autoFocus', True),
@ -308,7 +124,6 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
options = None options = None
def serialize(self, field, cstruct, **kw): def serialize(self, field, cstruct, **kw):
""" """
if 'delay' in kw or getattr(self, 'delay', None): if 'delay' in kw or getattr(self, 'delay', None):
raise ValueError( raise ValueError(
'AutocompleteWidget does not support *delay* parameter ' 'AutocompleteWidget does not support *delay* parameter '
@ -327,333 +142,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
kw['options'] = json.dumps(options) kw['options'] = json.dumps(options)
kw['field_display'] = self.field_display kw['field_display'] = self.field_display
kw['cleared_callback'] = self.cleared_callback 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) kw.setdefault('selected_callback', self.selected_callback)
tmpl_values = self.get_template_values(field, cstruct, kw) tmpl_values = self.get_template_values(field, cstruct, kw)
template = readonly and self.readonly_template or self.template template = readonly and self.readonly_template or self.template
return field.renderer(template, **tmpl_values) 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]

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -28,3 +28,4 @@ from __future__ import unicode_literals, absolute_import
from . import filters from . import filters
from .core import Grid, GridAction from .core import Grid, GridAction
from .mobile import MobileGrid

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2018 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,15 +24,16 @@
Grid Filters Grid Filters
""" """
import re from __future__ import unicode_literals, absolute_import
import datetime
import decimal
import logging
from collections import OrderedDict
import datetime
import logging
import six
import sqlalchemy as sa import sqlalchemy as sa
from rattail.gpc import GPC from rattail.gpc import GPC
from rattail.util import OrderedDict
from rattail.core import UNSPECIFIED from rattail.core import UNSPECIFIED
from rattail.time import localtime, make_utc from rattail.time import localtime, make_utc
from rattail.util import prettify from rattail.util import prettify
@ -50,7 +51,6 @@ class FilterValueRenderer(object):
""" """
Base class for all filter renderers. Base class for all filter renderers.
""" """
data_type = 'string'
@property @property
def name(self): def name(self):
@ -74,7 +74,6 @@ class NumericValueRenderer(FilterValueRenderer):
""" """
Input renderer for numeric values. Input renderer for numeric values.
""" """
data_type = 'number'
def render(self, value=None, **kwargs): def render(self, value=None, **kwargs):
kwargs.setdefault('step', '0.001') kwargs.setdefault('step', '0.001')
@ -85,18 +84,15 @@ class DateValueRenderer(FilterValueRenderer):
""" """
Input renderer for date values. Input renderer for date values.
""" """
data_type = 'date'
def render(self, value=None, **kwargs): def render(self, value=None, **kwargs):
kwargs['data-datepicker'] = 'true' return tags.text(self.name, value=value, type='date', **kwargs)
return tags.text(self.name, value=value, **kwargs)
class ChoiceValueRenderer(FilterValueRenderer): class ChoiceValueRenderer(FilterValueRenderer):
""" """
Renders value input as a dropdown/selectmenu of available choices. Renders value input as a dropdown/selectmenu of available choices.
""" """
data_type = 'choice'
def __init__(self, options): def __init__(self, options):
self.options = options self.options = options
@ -111,11 +107,8 @@ class EnumValueRenderer(ChoiceValueRenderer):
""" """
def __init__(self, enum): def __init__(self, enum):
if isinstance(enum, OrderedDict): sorted_keys = sorted(enum, key=lambda k: enum[k].lower())
sorted_keys = list(enum.keys()) self.options = [tags.Option(enum[k], six.text_type(k)) for k in sorted_keys]
else:
sorted_keys = sorted(enum, key=lambda k: enum[k].lower())
self.options = [tags.Option(enum[k], str(k)) for k in sorted_keys]
class GridFilter(object): class GridFilter(object):
@ -127,69 +120,37 @@ class GridFilter(object):
'is_any': "is any", 'is_any': "is any",
'equal': "equal to", 'equal': "equal to",
'not_equal': "not equal to", 'not_equal': "not equal to",
'equal_any_of': "equal to any of",
'greater_than': "greater than", 'greater_than': "greater than",
'greater_equal': "greater than or equal to", 'greater_equal': "greater than or equal to",
'less_than': "less than", 'less_than': "less than",
'less_equal': "less than or equal to", 'less_equal': "less than or equal to",
'is_empty': "is empty",
'is_not_empty': "is not empty",
'between': "between",
'is_null': "is null", 'is_null': "is null",
'is_not_null': "is not null", 'is_not_null': "is not null",
'is_true': "is true", 'is_true': "is true",
'is_false': "is false", 'is_false': "is false",
'is_false_null': "is false or null",
'is_empty_or_null': "is either empty or null",
'contains': "contains", 'contains': "contains",
'does_not_contain': "does not contain", 'does_not_contain': "does not contain",
'contains_any_of': "contains any of",
'is_me': "is me", 'is_me': "is me",
'is_not_me': "is not me", 'is_not_me': "is not me",
} }
valueless_verbs = [ valueless_verbs = ['is_any', 'is_null', 'is_not_null', 'is_true', 'is_false',
'is_any', 'is_me', 'is_not_me']
'is_empty',
'is_not_empty',
'is_null',
'is_not_null',
'is_true',
'is_false',
'is_false_null',
'is_empty_or_null',
'is_me',
'is_not_me',
]
multiple_value_verbs = [
'equal_any_of',
'contains_any_of',
]
value_renderer_factory = DefaultValueRenderer value_renderer_factory = DefaultValueRenderer
data_type = 'string' # default, but will be set from value renderer
choices = {}
def __init__(self, key, config=None, label=None, verbs=None, def __init__(self, key, label=None, verbs=None, value_enum=None, value_renderer=None,
value_enum=None, value_renderer=None,
default_active=False, default_verb=None, default_value=None, default_active=False, default_verb=None, default_value=None,
encode_values=False, value_encoding='utf-8', **kwargs): encode_values=False, value_encoding='utf-8', **kwargs):
self.key = key self.key = key
self.config = config
self.label = label or prettify(key) self.label = label or prettify(key)
self.verbs = verbs or self.get_default_verbs()
if value_renderer: if value_renderer:
self.set_value_renderer(value_renderer) self.set_value_renderer(value_renderer)
elif value_enum: elif value_enum:
self.set_choices(value_enum) self.set_value_renderer(EnumValueRenderer(value_enum))
else: else:
self.set_value_renderer(self.value_renderer_factory) self.set_value_renderer(self.value_renderer_factory)
# nb. do this after setting choices, if applicable, since that
# could change default verbs
self.verbs = verbs or self.get_default_verbs()
self.default_active = default_active self.default_active = default_active
self.default_verb = default_verb self.default_verb = default_verb
self.default_value = default_value self.default_value = default_value
@ -215,48 +176,6 @@ class GridFilter(object):
return verbs return verbs
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any'] return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
def normalize_choices(self, choices):
"""
Normalize a set of "choices" to a format suitable for use with the
filter.
:param choices: A collection of "choices" in one of the following
formats:
* simple list, each value of which should be a string, which is
assumed to be able to serve as both key and value (ordering of
choices will be preserved)
* simple dict, keys and values of which will define the choices
(note that the final choices will be sorted by key!)
* OrderedDict, keys and values of which will define the choices
(ordering of choices will be preserved)
"""
if isinstance(choices, OrderedDict):
normalized = choices
elif isinstance(choices, dict):
normalized = OrderedDict([
(key, choices[key])
for key in sorted(choices)])
elif isinstance(choices, list):
normalized = OrderedDict([
(key, key)
for key in choices])
return normalized
def set_choices(self, choices):
"""
Set the value choices for the filter. Note that this also will set the
value renderer to one which supports choices.
:param choices: A collection of "choices" which will be normalized by
way of :meth:`normalize_choices()`.
"""
self.choices = self.normalize_choices(choices)
self.set_value_renderer(ChoiceValueRenderer(self.choices))
def set_value_renderer(self, renderer): def set_value_renderer(self, renderer):
""" """
Set the value renderer for the filter, post-construction. Set the value renderer for the filter, post-construction.
@ -265,7 +184,6 @@ class GridFilter(object):
renderer = renderer() renderer = renderer()
renderer.filter = self renderer.filter = self
self.value_renderer = renderer self.value_renderer = renderer
self.data_type = renderer.data_type
def filter(self, data, verb=None, value=UNSPECIFIED): def filter(self, data, verb=None, value=UNSPECIFIED):
""" """
@ -277,15 +195,14 @@ class GridFilter(object):
value = self.get_value(value) value = self.get_value(value)
filtr = getattr(self, 'filter_{0}'.format(verb), None) filtr = getattr(self, 'filter_{0}'.format(verb), None)
if not filtr: if not filtr:
log.warning("unknown filter verb: %s", verb) raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
return data
return filtr(data, value) return filtr(data, value)
def get_value(self, value=UNSPECIFIED): def get_value(self, value=UNSPECIFIED):
return value if value is not UNSPECIFIED else self.value return value if value is not UNSPECIFIED else self.value
def encode_value(self, value): def encode_value(self, value):
if self.encode_values and isinstance(value, str): if self.encode_values and isinstance(value, six.string_types):
return value.encode('utf-8') return value.encode('utf-8')
return value return value
@ -307,6 +224,18 @@ class GridFilter(object):
return self.value_renderer.render(value=value, **kwargs) return self.value_renderer.render(value=value, **kwargs)
class MobileFilter(GridFilter):
"""
Base class for mobile grid filters.
"""
default_verbs = ['equal']
def __init__(self, key, **kwargs):
kwargs.setdefault('default_active', True)
kwargs.setdefault('default_verb', 'equal')
super(MobileFilter, self).__init__(key, **kwargs)
class AlchemyGridFilter(GridFilter): class AlchemyGridFilter(GridFilter):
""" """
Base class for SQLAlchemy grid filters. Base class for SQLAlchemy grid filters.
@ -314,7 +243,7 @@ class AlchemyGridFilter(GridFilter):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.column = kwargs.pop('column') self.column = kwargs.pop('column')
super().__init__(*args, **kwargs) super(AlchemyGridFilter, self).__init__(*args, **kwargs)
def filter_equal(self, query, value): def filter_equal(self, query, value):
""" """
@ -338,38 +267,6 @@ class AlchemyGridFilter(GridFilter):
self.column != self.encode_value(value), self.column != self.encode_value(value),
)) ))
def filter_equal_any_of(self, query, value):
"""
This filter expects "multiple values" separated by newline
character, and will add an "OR" condition with each value
being checked separately. For instance if the user submits a
"value" like this:
.. code-block:: none
foo bar
baz
This will result in SQL condition like this:
.. code-block:: sql
name = 'foo bar' OR name = 'baz'
"""
if not value:
return query
values = value.split('\n')
values = [value for value in values if value]
if not values:
return query
conditions = []
for value in values:
conditions.append(self.column == self.encode_value(value))
return query.filter(sa.or_(*conditions))
def filter_is_null(self, query, value): def filter_is_null(self, query, value):
""" """
Filter data with an 'IS NULL' query. Note that this filter does not Filter data with an 'IS NULL' query. Note that this filter does not
@ -416,47 +313,6 @@ class AlchemyGridFilter(GridFilter):
return query return query
return query.filter(self.column <= self.encode_value(value)) return query.filter(self.column <= self.encode_value(value))
def filter_between(self, query, value):
"""
Filter data with a "between" query. Really this uses ">=" and
"<=" (inclusive) logic instead of SQL "between" keyword.
"""
if value is None or value == '':
return query
if '|' not in value:
return query
values = value.split('|')
if len(values) != 2:
return query
start_value, end_value = values
# we'll only filter if we have start and/or end value
if not start_value and not end_value:
return query
return self.filter_for_range(query, start_value, end_value)
def filter_for_range(self, query, start_value, end_value):
"""
This method should actually apply filter(s) to the query,
according to the given value range. Subclasses may override
this logic.
"""
if start_value:
if self.value_invalid(start_value):
return query
query = query.filter(self.column >= self.encode_value(start_value))
if end_value:
if self.value_invalid(end_value):
return query
query = query.filter(self.column <= self.encode_value(end_value))
return query
class AlchemyStringFilter(AlchemyGridFilter): class AlchemyStringFilter(AlchemyGridFilter):
""" """
@ -467,17 +323,8 @@ class AlchemyStringFilter(AlchemyGridFilter):
""" """
Expose contains / does-not-contain verbs in addition to core. Expose contains / does-not-contain verbs in addition to core.
""" """
if self.choices:
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
return ['contains', 'does_not_contain', return ['contains', 'does_not_contain',
'contains_any_of', 'equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
'equal', 'not_equal', 'equal_any_of',
'is_empty', 'is_not_empty',
'is_null', 'is_not_null',
'is_empty_or_null',
'is_any']
def filter_contains(self, query, value): def filter_contains(self, query, value):
""" """
@ -485,13 +332,9 @@ class AlchemyStringFilter(AlchemyGridFilter):
""" """
if value is None or value == '': if value is None or value == '':
return query return query
return query.filter(sa.and_(
criteria = [] *[self.column.ilike(self.encode_value('%{}%'.format(v)))
for val in value.split(): for v in value.split()]))
val = val.replace('_', r'\_')
val = self.encode_value(f'%{val}%')
criteria.append(self.column.ilike(val))
return query.filter(sa.and_(*criteria))
def filter_does_not_contain(self, query, value): def filter_does_not_contain(self, query, value):
""" """
@ -500,65 +343,14 @@ class AlchemyStringFilter(AlchemyGridFilter):
if value is None or value == '': if value is None or value == '':
return query return query
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = self.encode_value(f'%{val}%')
criteria.append(~self.column.ilike(val))
# When saying something is 'not like' something else, we must also # When saying something is 'not like' something else, we must also
# include things which are nothing at all, in our result set. # include things which are nothing at all, in our result set.
return query.filter(sa.or_( return query.filter(sa.or_(
self.column == None, self.column == None,
sa.and_(*criteria))) sa.and_(
*[~self.column.ilike(self.encode_value('%{}%'.format(v)))
def filter_contains_any_of(self, query, value): for v in value.split()]),
""" ))
This filter expects "multiple values" separated by newline character,
and will add an "OR" condition with each value being checked via
"ILIKE". For instance if the user submits a "value" like this:
.. code-block:: none
foo bar
baz
This will result in SQL condition like this:
.. code-block:: sql
(name ILIKE '%foo%' AND name ILIKE '%bar%') OR name ILIKE '%baz%'
"""
if not value:
return query
values = value.split('\n')
values = [value for value in values if value]
if not values:
return query
conditions = []
for value in values:
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = self.encode_value(f'%{val}%')
criteria.append(self.column.ilike(val))
conditions.append(sa.and_(*criteria))
return query.filter(sa.or_(*conditions))
def filter_is_empty(self, query, value):
return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))
def filter_is_not_empty(self, query, value):
return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))
def filter_is_empty_or_null(self, query, value):
return query.filter(
sa.or_(
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''),
self.column == None))
class AlchemyEmptyStringFilter(AlchemyStringFilter): class AlchemyEmptyStringFilter(AlchemyStringFilter):
@ -570,13 +362,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter):
return query.filter( return query.filter(
sa.or_( sa.or_(
self.column == None, self.column == None,
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))) sa.func.trim(self.column) == self.encode_value('')))
def filter_is_not_null(self, query, value): def filter_is_not_null(self, query, value):
return query.filter( return query.filter(
sa.and_( sa.and_(
self.column != None, self.column != None,
sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))) sa.func.trim(self.column) != self.encode_value('')))
class AlchemyByteStringFilter(AlchemyStringFilter): class AlchemyByteStringFilter(AlchemyStringFilter):
@ -588,8 +380,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
value_encoding = 'utf-8' value_encoding = 'utf-8'
def get_value(self, value=UNSPECIFIED): def get_value(self, value=UNSPECIFIED):
value = super().get_value(value) value = super(AlchemyByteStringFilter, self).get_value(value)
if isinstance(value, str): if isinstance(value, six.text_type):
value = value.encode(self.value_encoding) value = value.encode(self.value_encoding)
return value return value
@ -599,13 +391,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
""" """
if value is None or value == '': if value is None or value == '':
return query return query
return query.filter(sa.and_(
criteria = [] *[self.column.ilike(b'%{}%'.format(v)) for v in value.split()]))
for val in value.split():
val = val.replace('_', r'\_')
val = b'%{}%'.format(val)
criteria.append(self.column.ilike(val))
return query.filters(sa.and_(*criteria))
def filter_does_not_contain(self, query, value): def filter_does_not_contain(self, query, value):
""" """
@ -614,16 +401,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
if value is None or value == '': if value is None or value == '':
return query return query
for val in value.split():
val = val.replace('_', '\_')
val = b'%{}%'.format(val)
criteria.append(~self.column.ilike(val))
# When saying something is 'not like' something else, we must also # When saying something is 'not like' something else, we must also
# include things which are nothing at all, in our result set. # include things which are nothing at all, in our result set.
return query.filter(sa.or_( return query.filter(sa.or_(
self.column == None, self.column == None,
sa.and_(*criteria))) sa.and_(
*[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]),
))
class AlchemyNumericFilter(AlchemyGridFilter): class AlchemyNumericFilter(AlchemyGridFilter):
@ -632,11 +416,9 @@ class AlchemyNumericFilter(AlchemyGridFilter):
""" """
value_renderer_factory = NumericValueRenderer value_renderer_factory = NumericValueRenderer
def default_verbs(self): # expose greater-than / less-than verbs in addition to core
# expose greater-than / less-than verbs in addition to core default_verbs = ['equal', 'not_equal', 'greater_than', 'greater_equal',
return ['equal', 'not_equal', 'greater_than', 'greater_equal', 'less_than', 'less_equal', 'is_null', 'is_not_null', 'is_any']
'less_than', 'less_equal', 'between',
'is_null', 'is_not_null', 'is_any']
# TODO: what follows "works" in that it prevents an error...but from the # TODO: what follows "works" in that it prevents an error...but from the
# user's perspective it still fails silently...need to improve on front-end # user's perspective it still fails silently...need to improve on front-end
@ -645,95 +427,50 @@ class AlchemyNumericFilter(AlchemyGridFilter):
# term for integer field... # term for integer field...
def value_invalid(self, value): def value_invalid(self, value):
return bool(value and len(six.text_type(value)) > 8)
# first just make sure it's somewhat numeric
try:
self.parse_decimal(value)
except decimal.InvalidOperation:
return True
return bool(value and len(str(value)) > 8)
def parse_decimal(self, value):
if value:
value = value.replace(',', '')
return decimal.Decimal(value)
def encode_value(self, value):
if value:
value = str(self.parse_decimal(value))
return super().encode_value(value)
def filter_equal(self, query, value): def filter_equal(self, query, value):
if self.value_invalid(value): if self.value_invalid(value):
return query return query
return super().filter_equal(query, value) return super(AlchemyNumericFilter, self).filter_equal(query, value)
def filter_not_equal(self, query, value): def filter_not_equal(self, query, value):
if self.value_invalid(value): if self.value_invalid(value):
return query return query
return super().filter_not_equal(query, value) return super(AlchemyNumericFilter, self).filter_not_equal(query, value)
def filter_greater_than(self, query, value): def filter_greater_than(self, query, value):
if self.value_invalid(value): if self.value_invalid(value):
return query return query
return super().filter_greater_than(query, value) return super(AlchemyNumericFilter, self).filter_greater_than(query, value)
def filter_greater_equal(self, query, value): def filter_greater_equal(self, query, value):
if self.value_invalid(value): if self.value_invalid(value):
return query return query
return super().filter_greater_equal(query, value) return super(AlchemyNumericFilter, self).filter_greater_equal(query, value)
def filter_less_than(self, query, value): def filter_less_than(self, query, value):
if self.value_invalid(value): if self.value_invalid(value):
return query return query
return super().filter_less_than(query, value) return super(AlchemyNumericFilter, self).filter_less_than(query, value)
def filter_less_equal(self, query, value): def filter_less_equal(self, query, value):
if self.value_invalid(value): if self.value_invalid(value):
return query return query
return super().filter_less_equal(query, value) return super(AlchemyNumericFilter, self).filter_less_equal(query, value)
class AlchemyIntegerFilter(AlchemyNumericFilter): class AlchemyIntegerFilter(AlchemyNumericFilter):
""" """
Integer filter for SQLAlchemy. Integer filter for SQLAlchemy.
""" """
bigint = False
def default_verbs(self):
# limited verbs if choices are defined
if self.choices:
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
return super().default_verbs()
def value_invalid(self, value): def value_invalid(self, value):
if value: if value:
if isinstance(value, int):
return True
if not value.isdigit(): if not value.isdigit():
return True return True
# normal Integer columns have a max value, beyond which PG
# will throw an error if we try to query for larger values
# TODO: this seems hacky, how to better handle it?
if not self.bigint and int(value) > 2147483647:
return True
return False return False
def encode_value(self, value):
# ensure we pass integer value to sqlalchemy, so it does not try to
# encode it as a string etc.
return int(value)
class AlchemyBigIntegerFilter(AlchemyIntegerFilter):
"""
BigInteger filter for SQLAlchemy.
"""
bigint = True
class AlchemyBooleanFilter(AlchemyGridFilter): class AlchemyBooleanFilter(AlchemyGridFilter):
""" """
@ -760,16 +497,7 @@ class AlchemyNullableBooleanFilter(AlchemyBooleanFilter):
""" """
Boolean filter for SQLAlchemy which is NULL-aware. Boolean filter for SQLAlchemy which is NULL-aware.
""" """
default_verbs = ['is_true', 'is_false', 'is_false_null', default_verbs = ['is_true', 'is_false', 'is_null', 'is_not_null', 'is_any']
'is_null', 'is_not_null', 'is_any']
def filter_is_false_null(self, query, value):
"""
Filter data with an "is false or null" query. Note that this filter
does not use the value for anything.
"""
return query.filter(sa.or_(self.column == False,
self.column == None))
class AlchemyDateFilter(AlchemyGridFilter): class AlchemyDateFilter(AlchemyGridFilter):
@ -785,7 +513,6 @@ class AlchemyDateFilter(AlchemyGridFilter):
'greater_equal': "on or after", 'greater_equal': "on or after",
'less_than': "before", 'less_than': "before",
'less_equal': "on or before", 'less_equal': "on or before",
'between': "between",
'is_null': "is null", 'is_null': "is null",
'is_not_null': "is not null", 'is_not_null': "is not null",
'is_any': "is any", 'is_any': "is any",
@ -795,27 +522,14 @@ class AlchemyDateFilter(AlchemyGridFilter):
""" """
Expose greater-than / less-than verbs in addition to core. Expose greater-than / less-than verbs in addition to core.
""" """
return [ return ['equal', 'not_equal', 'greater_than', 'greater_equal',
'equal', 'less_than', 'less_equal', 'is_null', 'is_not_null', 'is_any']
'not_equal',
'greater_than',
'greater_equal',
'less_than',
'less_equal',
'between',
'is_null',
'is_not_null',
'is_any',
]
def make_date(self, value): def make_date(self, value):
""" """
Convert user input to a proper ``datetime.date`` object. Convert user input to a proper ``datetime.date`` object.
""" """
if value: if value:
if isinstance(value, datetime.date):
return value
try: try:
dt = datetime.datetime.strptime(value, '%Y-%m-%d') dt = datetime.datetime.strptime(value, '%Y-%m-%d')
except ValueError: except ValueError:
@ -823,87 +537,6 @@ class AlchemyDateFilter(AlchemyGridFilter):
else: else:
return dt.date() return dt.date()
def filter_equal(self, query, value):
date = self.make_date(value)
if not date:
return query
return query.filter(self.column == self.encode_value(date))
def filter_not_equal(self, query, value):
date = self.make_date(value)
if not date:
return query
return query.filter(sa.or_(
self.column == None,
self.column != self.encode_value(date),
))
def filter_greater_than(self, query, value):
date = self.make_date(value)
if not date:
return query
return query.filter(self.column > self.encode_value(date))
def filter_greater_equal(self, query, value):
date = self.make_date(value)
if not date:
return query
return query.filter(self.column >= self.encode_value(date))
def filter_less_than(self, query, value):
date = self.make_date(value)
if not date:
return query
return query.filter(self.column < self.encode_value(date))
def filter_less_equal(self, query, value):
date = self.make_date(value)
if not date:
return query
return query.filter(self.column <= self.encode_value(date))
# TODO: this should be merged into parent class
def filter_between(self, query, value):
"""
Filter data with a "between" query. Really this uses ">=" and "<="
(inclusive) logic instead of SQL "between" keyword.
"""
if value is None or value == '':
return query
if '|' not in value:
return query
values = value.split('|')
if len(values) != 2:
return query
start_date, end_date = values
if start_date:
start_date = self.make_date(start_date)
if end_date:
end_date = self.make_date(end_date)
# we'll only filter if we have start and/or end date
if not start_date and not end_date:
return query
return self.filter_date_range(query, start_date, end_date)
# TODO: this should be merged into parent class
def filter_date_range(self, query, start_date, end_date):
"""
This method should actually apply filter(s) to the query, according to
the given date range. Subclasses may override this logic.
"""
if start_date:
query = query.filter(self.column >= start_date)
if end_date:
query = query.filter(self.column <= end_date)
return query
class AlchemyDateTimeFilter(AlchemyDateFilter): class AlchemyDateTimeFilter(AlchemyDateFilter):
""" """
@ -993,17 +626,6 @@ class AlchemyDateTimeFilter(AlchemyDateFilter):
time = make_utc(localtime(self.config, time)) time = make_utc(localtime(self.config, time))
return query.filter(self.column < time) return query.filter(self.column < time)
def filter_date_range(self, query, start_date, end_date):
if start_date:
start_time = datetime.datetime.combine(start_date, datetime.time(0))
start_time = localtime(self.config, start_time)
query = query.filter(self.column >= make_utc(start_time))
if end_date:
end_time = datetime.datetime.combine(end_date + datetime.timedelta(days=1), datetime.time(0))
end_time = localtime(self.config, end_time)
query = query.filter(self.column <= make_utc(end_time))
return query
class AlchemyLocalDateTimeFilter(AlchemyDateTimeFilter): class AlchemyLocalDateTimeFilter(AlchemyDateTimeFilter):
""" """
@ -1094,36 +716,19 @@ class AlchemyLocalDateTimeFilter(AlchemyDateTimeFilter):
time = localtime(self.config, time, tzinfo=False) time = localtime(self.config, time, tzinfo=False)
return query.filter(self.column < time) return query.filter(self.column < time)
def filter_date_range(self, query, start_date, end_date):
if start_date:
start_time = datetime.datetime.combine(start_date, datetime.time(0))
start_time = localtime(self.config, start_time, tzinfo=False)
query = query.filter(self.column >= start_time)
if end_date:
end_time = datetime.datetime.combine(end_date + datetime.timedelta(days=1), datetime.time(0))
end_time = localtime(self.config, end_time, tzinfo=False)
query = query.filter(self.column <= end_time)
return query
class AlchemyGPCFilter(AlchemyGridFilter): class AlchemyGPCFilter(AlchemyGridFilter):
""" """
GPC filter for SQLAlchemy. GPC filter for SQLAlchemy.
""" """
default_verbs = ['equal', 'not_equal', 'equal_any_of', default_verbs = ['equal', 'not_equal']
'is_null', 'is_not_null']
def filter_equal(self, query, value): def filter_equal(self, query, value):
""" """
Filter data with an equal ('=') query. Filter data with an equal ('=') query.
""" """
if value is None: if value is None or value == '':
return query return query
value = value.strip()
if not value:
return query
try: try:
return query.filter(self.column.in_(( return query.filter(self.column.in_((
GPC(value), GPC(value),
@ -1133,13 +738,9 @@ class AlchemyGPCFilter(AlchemyGridFilter):
def filter_not_equal(self, query, value): def filter_not_equal(self, query, value):
""" """
Filter data with a not equal ('!=') query. Filter data with a not eqaul ('!=') query.
""" """
if value is None: if value is None or value == '':
return query
value = value.strip()
if not value:
return query return query
# When saying something is 'not equal' to something else, we must also # When saying something is 'not equal' to something else, we must also
@ -1153,114 +754,12 @@ class AlchemyGPCFilter(AlchemyGridFilter):
except ValueError: except ValueError:
return query return query
def filter_equal_any_of(self, query, value):
"""
This filter expects "multiple values" separated by newline character,
and will add an "OR" condition with each value being checked via
"ILIKE". For instance if the user submits a "value" like this:
.. code-block:: none
07430500132
07430500116
This will result in SQL condition like this:
.. code-block:: sql
(upc IN (7430500132, 74305001321)) OR (upc IN (7430500116, 74305001161))
"""
if not value:
return query
values = value.split('\n')
values = [value for value in values if value]
if not values:
return query
conditions = []
for value in values:
try:
clause = self.column.in_((
GPC(value),
GPC(value, calc_check_digit='upc')))
except ValueError:
pass
else:
conditions.append(clause)
if not conditions:
return query
return query.filter(sa.or_(*conditions))
class AlchemyPhoneNumberFilter(AlchemyStringFilter):
"""
Special string filter, with logic to deal with phone numbers.
"""
def parse_value(self, value):
newvalue = None
# first we try to split according to typical 7- or 10-digit number
digits = re.sub(r'\D', '', value or '')
if len(digits) == 7:
newvalue = "{} {}".format(digits[:3], digits[3:])
elif len(digits) == 10:
newvalue = "{} {} {}".format(digits[:3], digits[3:6], digits[6:])
# if that didn't work, we can also try to split by grouped digits
if not newvalue and value:
parts = re.split(r'\D+', value)
newvalue = ' '.join(parts)
return newvalue or value
def filter_contains(self, query, value):
"""
Try to parse the value into "parts" of a phone number, then do a normal
'ILIKE' query with those parts.
"""
value = self.parse_value(value)
return super().filter_contains(query, value)
def filter_does_not_contain(self, query, value):
"""
Try to parse the value into "parts" of a phone number, then do a normal
'NOT ILIKE' query with those parts.
"""
value = self.parse_value(value)
return super().filter_does_not_contain(query, value)
class GridFilterSet(OrderedDict): class GridFilterSet(OrderedDict):
""" """
Collection class for :class:`GridFilter` instances. Collection class for :class:`GridFilter` instances.
""" """
def move_before(self, key, refkey):
"""
Rearrange underlying key sorting, such that the given ``key`` comes
just *before* the given ``refkey``.
"""
# first must work out the new order for all keys
newkeys = []
for k in self.keys():
if k == key:
continue
if k == refkey:
newkeys.append(key)
newkeys.append(refkey)
else:
newkeys.append(k)
# then effectively replace dict contents, using new order
items = dict(self)
self.clear()
for k in newkeys:
self[k] = items[k]
class GridFiltersForm(forms.Form): class GridFiltersForm(forms.Form):
""" """
@ -1275,7 +774,7 @@ class GridFiltersForm(forms.Form):
node = colander.SchemaNode(colander.String(), name=key) node = colander.SchemaNode(colander.String(), name=key)
schema.add(node) schema.add(node)
kwargs['schema'] = schema kwargs['schema'] = schema
super().__init__(**kwargs) super(GridFiltersForm, self).__init__(**kwargs)
def iter_filters(self): def iter_filters(self):
return self.filters.values() return self.filters.values()

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -21,30 +21,33 @@
# #
################################################################################ ################################################################################
""" """
Trainwreck "default" views (i.e. assuming "default" schema) Mobile Grids
""" """
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
from rattail.trainwreck.db.model import defaults as trainwreck from pyramid.renderers import render
from tailbone.views.trainwreck import base from .core import Grid
class TransactionView(base.TransactionView): class MobileGrid(Grid):
""" """
Master view for Trainwreck transactions Base class for all mobile grids
""" """
model_class = trainwreck.Transaction
model_row_class = trainwreck.TransactionItem
def render_filters(self, template='/mobile/grids/filters_simple.mako', **kwargs):
context = kwargs
context['request'] = self.request
context['grid'] = self
return render(template, context)
def defaults(config, **kwargs): def render_grid(self, template='/mobile/grids/grid.mako', **kwargs):
base = globals() context = kwargs
context['grid'] = self
return render(template, context)
TransactionView = kwargs.get('TransactionView', base['TransactionView']) def render_complete(self, template='/mobile/grids/complete.mako', **kwargs):
TransactionView.defaults(config) context = kwargs
context['grid'] = self
return render(template, context)
def includeme(config):
defaults(config)

View file

@ -1,82 +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 Handler
"""
import warnings
from mako.lookup import TemplateLookup
from rattail.app import GenericHandler
from rattail.files import resource_path
from tailbone.providers import get_all_providers
class TailboneHandler(GenericHandler):
"""
Base class and default implementation for Tailbone handler.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# TODO: make templates dir configurable?
templates = [resource_path('rattail:templates/web')]
self.templates = TemplateLookup(directories=templates)
def get_menu_handler(self, **kwargs):
"""
DEPRECATED; use
:meth:`wuttaweb.handler.WebHandler.get_menu_handler()`
instead.
"""
warnings.warn("TailboneHandler.get_menu_handler() is deprecated; "
"please use WebHandler.get_menu_handler() instead",
DeprecationWarning, stacklevel=2)
if not hasattr(self, 'menu_handler'):
spec = self.config.get('tailbone.menus', 'handler',
default='tailbone.menus:MenuHandler')
Handler = self.app.load_object(spec)
self.menu_handler = Handler(self.config)
self.menu_handler.tb = self
return self.menu_handler
def iter_providers(self):
"""
Returns an iterator over all registered Tailbone providers.
"""
providers = get_all_providers(self.config)
return providers.values()
def write_model_view(self, data, path, **kwargs):
"""
Write code for a new model view, based on the given data dict,
to the given path.
"""
template = self.templates.get_template('/new-model-view.mako')
content = template.render(**data)
with open(path, 'wt') as f:
f.write(content)

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,21 +24,18 @@
Template Context Helpers Template Context Helpers
""" """
# start off with all from wuttaweb from __future__ import unicode_literals, absolute_import
from wuttaweb.helpers import *
import os
import datetime import datetime
from decimal import Decimal from decimal import Decimal
from collections import OrderedDict
from rattail.time import localtime, make_utc from rattail.time import localtime, make_utc
from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal from rattail.util import pretty_quantity, pretty_hours
from rattail.db.util import maxlen
from tailbone.util import (pretty_datetime, raw_datetime, from webhelpers2.html import *
render_markdown, from webhelpers2.html.tags import *
route_exists)
from tailbone.util import csrf_token, pretty_datetime
def pretty_date(date): def pretty_date(date):

View file

@ -1,772 +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/>.
#
################################################################################
"""
App Menus
"""
import logging
import warnings
from rattail.util import prettify, simple_error
from webhelpers2.html import tags, HTML
from wuttaweb.menus import MenuHandler as WuttaMenuHandler
from tailbone.db import Session
log = logging.getLogger(__name__)
class TailboneMenuHandler(WuttaMenuHandler):
"""
Base class and default implementation for menu handler.
"""
##############################
# internal methods
##############################
def _is_allowed(self, request, item):
"""
TODO: must override this until wuttaweb has proper user auth checks
"""
perm = item.get('perm')
if perm:
return request.has_perm(perm)
return True
def _make_raw_menus(self, request, **kwargs):
"""
We are overriding this to allow for making dynamic menus from
config/settings. Which may or may not be a good idea..
"""
# first try to make menus from config, but this is highly
# susceptible to failure, so try to warn user of problems
try:
menus = self._make_menus_from_config(request)
if menus:
return menus
except Exception as error:
# TODO: these messages show up multiple times on some pages?!
# that must mean the BeforeRender event is firing multiple
# times..but why?? seems like there is only 1 request...
log.warning("failed to make menus from config", exc_info=True)
request.session.flash(simple_error(error), 'error')
request.session.flash("Menu config is invalid! Reverting to menus "
"defined in code!", 'warning')
msg = HTML.literal('Please edit your {} ASAP.'.format(
tags.link_to("Menu Config", request.route_url('configure_menus'))))
request.session.flash(msg, 'warning')
# okay, no config, so menus will be built from code
return self.make_menus(request, **kwargs)
def _make_menus_from_config(self, request, **kwargs):
"""
Try to build a complete menu set from config/settings.
This will look in the DB settings table, or config file, for
menu data. If found, it constructs menus from that data.
"""
# bail unless config defines top-level menu keys
main_keys = self.config.getlist('tailbone.menu', 'menus')
if not main_keys:
return
model = self.app.model
menus = []
# menu definition can come either from config file or db
# settings, but if the latter then we want to optimize with
# one big query
if self.config.getbool('tailbone.menu', 'from_settings',
default=False):
# fetch all menu-related settings at once
query = Session().query(model.Setting)\
.filter(model.Setting.name.like('tailbone.menu.%'))
settings = self.app.cache_model(Session(), model.Setting,
query=query, key='name',
normalizer=lambda s: s.value)
for key in main_keys:
menus.append(self._make_single_menu_from_settings(request, key, settings))
else: # read from config file only
for key in main_keys:
menus.append(self._make_single_menu_from_config(request, key))
return menus
def _make_single_menu_from_config(self, request, key, **kwargs):
"""
Makes a single top-level menu dict from config file. Note
that this will read from config file(s) *only* and avoids
querying the database, for efficiency.
"""
menu = {
'key': key,
'type': 'menu',
'items': [],
}
# title
title = self.config.get('tailbone.menu',
'menu.{}.label'.format(key),
usedb=False)
menu['title'] = title or prettify(key)
# items
item_keys = self.config.getlist('tailbone.menu',
'menu.{}.items'.format(key),
usedb=False)
for item_key in item_keys:
item = {}
if item_key == 'SEP':
item['type'] = 'sep'
else:
item['type'] = 'item'
item['key'] = item_key
# title
title = self.config.get('tailbone.menu',
'menu.{}.item.{}.label'.format(key, item_key),
usedb=False)
item['title'] = title or prettify(item_key)
# route
route = self.config.get('tailbone.menu',
'menu.{}.item.{}.route'.format(key, item_key),
usedb=False)
if route:
item['route'] = route
item['url'] = request.route_url(route)
else:
# url
url = self.config.get('tailbone.menu',
'menu.{}.item.{}.url'.format(key, item_key),
usedb=False)
if not url:
url = request.route_url(item_key)
elif url.startswith('route:'):
url = request.route_url(url[6:])
item['url'] = url
# perm
perm = self.config.get('tailbone.menu',
'menu.{}.item.{}.perm'.format(key, item_key),
usedb=False)
item['perm'] = perm or '{}.list'.format(item_key)
menu['items'].append(item)
return menu
def _make_single_menu_from_settings(self, request, key, settings, **kwargs):
"""
Makes a single top-level menu dict from DB settings.
"""
menu = {
'key': key,
'type': 'menu',
'items': [],
}
# title
title = settings.get('tailbone.menu.menu.{}.label'.format(key))
menu['title'] = title or prettify(key)
# items
item_keys = self.config.parse_list(
settings.get('tailbone.menu.menu.{}.items'.format(key)))
for item_key in item_keys:
item = {}
if item_key == 'SEP':
item['type'] = 'sep'
else:
item['type'] = 'item'
item['key'] = item_key
# title
title = settings.get('tailbone.menu.menu.{}.item.{}.label'.format(
key, item_key))
item['title'] = title or prettify(item_key)
# route
route = settings.get('tailbone.menu.menu.{}.item.{}.route'.format(
key, item_key))
if route:
item['route'] = route
item['url'] = request.route_url(route)
else:
# url
url = settings.get('tailbone.menu.menu.{}.item.{}.url'.format(
key, item_key))
if not url:
url = request.route_url(item_key)
if url.startswith('route:'):
url = request.route_url(url[6:])
item['url'] = url
# perm
perm = settings.get('tailbone.menu.menu.{}.item.{}.perm'.format(
key, item_key))
item['perm'] = perm or '{}.list'.format(item_key)
menu['items'].append(item)
return menu
##############################
# menu defaults
##############################
def make_menus(self, request, **kwargs):
"""
Make the full set of menus for the app.
This method provides a semi-sane menu set by default, but it
is expected for most apps to override it.
"""
menus = [
self.make_custorders_menu(request),
self.make_people_menu(request),
self.make_products_menu(request),
self.make_vendors_menu(request),
]
integration_menus = self.make_integration_menus(request)
if integration_menus:
menus.extend(integration_menus)
menus.extend([
self.make_reports_menu(request, include_trainwreck=True),
self.make_batches_menu(request),
self.make_admin_menu(request, include_stores=True),
])
return menus
def make_integration_menus(self, request, **kwargs):
"""
Make a set of menus for all registered system integrations.
"""
tb = self.app.get_tailbone_handler()
menus = []
for provider in tb.iter_providers():
menu = provider.make_integration_menu(request)
if menu:
menus.append(menu)
menus.sort(key=lambda menu: menu['title'].lower())
return menus
def make_custorders_menu(self, request, **kwargs):
"""
Generate a typical Customer Orders menu
"""
return {
'title': "Orders",
'type': 'menu',
'items': [
{
'title': "New Customer Order",
'route': 'custorders.create',
'perm': 'custorders.create',
},
{
'title': "All New Orders",
'route': 'new_custorders',
'perm': 'new_custorders.list',
},
{'type': 'sep'},
{
'title': "All Customer Orders",
'route': 'custorders',
'perm': 'custorders.list',
},
{
'title': "All Order Items",
'route': 'custorders.items',
'perm': 'custorders.items.list',
},
],
}
def make_people_menu(self, request, **kwargs):
"""
Generate a typical People menu
"""
return {
'title': "People",
'type': 'menu',
'items': [
{
'title': "Members",
'route': 'members',
'perm': 'members.list',
},
{
'title': "Member Equity Payments",
'route': 'member_equity_payments',
'perm': 'member_equity_payments.list',
},
{
'title': "Membership Types",
'route': 'membership_types',
'perm': 'membership_types.list',
},
{'type': 'sep'},
{
'title': "Customers",
'route': 'customers',
'perm': 'customers.list',
},
{
'title': "Customer Shoppers",
'route': 'customer_shoppers',
'perm': 'customer_shoppers.list',
},
{
'title': "Customer Groups",
'route': 'customergroups',
'perm': 'customergroups.list',
},
{
'title': "Pending Customers",
'route': 'pending_customers',
'perm': 'pending_customers.list',
},
{'type': 'sep'},
{
'title': "Employees",
'route': 'employees',
'perm': 'employees.list',
},
{'type': 'sep'},
{
'title': "All People",
'route': 'people',
'perm': 'people.list',
},
],
}
def make_products_menu(self, request, **kwargs):
"""
Generate a typical Products menu
"""
return {
'title': "Products",
'type': 'menu',
'items': [
{
'title': "Products",
'route': 'products',
'perm': 'products.list',
},
{
'title': "Product Costs",
'route': 'product_costs',
'perm': 'product_costs.list',
},
{
'title': "Departments",
'route': 'departments',
'perm': 'departments.list',
},
{
'title': "Subdepartments",
'route': 'subdepartments',
'perm': 'subdepartments.list',
},
{
'title': "Brands",
'route': 'brands',
'perm': 'brands.list',
},
{
'title': "Categories",
'route': 'categories',
'perm': 'categories.list',
},
{
'title': "Families",
'route': 'families',
'perm': 'families.list',
},
{
'title': "Report Codes",
'route': 'reportcodes',
'perm': 'reportcodes.list',
},
{
'title': "Units of Measure",
'route': 'uoms',
'perm': 'uoms.list',
},
{'type': 'sep'},
{
'title': "Pending Products",
'route': 'pending_products',
'perm': 'pending_products.list',
},
],
}
def make_vendors_menu(self, request, **kwargs):
"""
Generate a typical Vendors menu
"""
return {
'title': "Vendors",
'type': 'menu',
'items': [
{
'title': "Vendors",
'route': 'vendors',
'perm': 'vendors.list',
},
{
'title': "Product Costs",
'route': 'product_costs',
'perm': 'product_costs.list',
},
{'type': 'sep'},
{
'title': "Ordering",
'route': 'ordering',
'perm': 'ordering.list',
},
{
'title': "Receiving",
'route': 'receiving',
'perm': 'receiving.list',
},
{
'title': "Invoice Costing",
'route': 'invoice_costing',
'perm': 'invoice_costing.list',
},
{'type': 'sep'},
{
'title': "Purchases",
'route': 'purchases',
'perm': 'purchases.list',
},
{
'title': "Credits",
'route': 'purchases.credits',
'perm': 'purchases.credits.list',
},
{'type': 'sep'},
{
'title': "Catalog Batches",
'route': 'vendorcatalogs',
'perm': 'vendorcatalogs.list',
},
{'type': 'sep'},
{
'title': "Sample Files",
'route': 'vendorsamplefiles',
'perm': 'vendorsamplefiles.list',
},
],
}
def make_batches_menu(self, request, **kwargs):
"""
Generate a typical Batches menu
"""
return {
'title': "Batches",
'type': 'menu',
'items': [
{
'title': "Handheld",
'route': 'batch.handheld',
'perm': 'batch.handheld.list',
},
{
'title': "Inventory",
'route': 'batch.inventory',
'perm': 'batch.inventory.list',
},
{
'title': "Import / Export",
'route': 'batch.importer',
'perm': 'batch.importer.list',
},
{
'title': "POS",
'route': 'batch.pos',
'perm': 'batch.pos.list',
},
],
}
def make_reports_menu(self, request, **kwargs):
"""
Generate a typical Reports menu
"""
items = [
{
'title': "New Report",
'route': 'report_output.create',
'perm': 'report_output.create',
},
{
'title': "Generated Reports",
'route': 'report_output',
'perm': 'report_output.list',
},
{
'title': "Problem Reports",
'route': 'problem_reports',
'perm': 'problem_reports.list',
},
]
if kwargs.get('include_poser', False):
items.extend([
{'type': 'sep'},
{
'title': "Poser Reports",
'route': 'poser_reports',
'perm': 'poser_reports.list',
},
])
if kwargs.get('include_worksheets', False):
items.extend([
{'type': 'sep'},
{
'title': "Ordering Worksheet",
'route': 'reports.ordering',
},
{
'title': "Inventory Worksheet",
'route': 'reports.inventory',
},
])
if kwargs.get('include_trainwreck', False):
items.extend([
{'type': 'sep'},
{
'title': "Trainwreck",
'route': 'trainwreck.transactions',
'perm': 'trainwreck.transactions.list',
},
])
return {
'title': "Reports",
'type': 'menu',
'items': items,
}
def make_tempmon_menu(self, request, **kwargs):
"""
Generate a typical TempMon menu
"""
return {
'title': "TempMon",
'type': 'menu',
'items': [
{
'title': "Dashboard",
'route': 'tempmon.dashboard',
'perm': 'tempmon.appliances.dashboard',
},
{'type': 'sep'},
{
'title': "Appliances",
'route': 'tempmon.appliances',
'perm': 'tempmon.appliances.list',
},
{
'title': "Clients",
'route': 'tempmon.clients',
'perm': 'tempmon.clients.list',
},
{
'title': "Probes",
'route': 'tempmon.probes',
'perm': 'tempmon.probes.list',
},
{
'title': "Readings",
'route': 'tempmon.readings',
'perm': 'tempmon.readings.list',
},
],
}
def make_admin_menu(self, request, **kwargs):
"""
Generate a typical Admin menu
"""
items = []
include_stores = kwargs.get('include_stores', True)
include_tenders = kwargs.get('include_tenders', True)
if include_stores or include_tenders:
if include_stores:
items.extend([
{
'title': "Stores",
'route': 'stores',
'perm': 'stores.list',
},
])
if include_tenders:
items.extend([
{
'title': "Tenders",
'route': 'tenders',
'perm': 'tenders.list',
},
])
items.append({'type': 'sep'})
items.extend([
{
'title': "Users",
'route': 'users',
'perm': 'users.list',
},
{
'title': "Roles",
'route': 'roles',
'perm': 'roles.list',
},
{
'title': "Raw Permissions",
'route': 'permissions',
'perm': 'permissions.list',
},
{'type': 'sep'},
{
'title': "Email Settings",
'route': 'emailprofiles',
'perm': 'emailprofiles.list',
},
{
'title': "Email Attempts",
'route': 'email_attempts',
'perm': 'email_attempts.list',
},
{'type': 'sep'},
{
'title': "DataSync Status",
'route': 'datasync.status',
'perm': 'datasync.status',
},
{
'title': "DataSync Changes",
'route': 'datasyncchanges',
'perm': 'datasync_changes.list',
},
{
'title': "Importing / Exporting",
'route': 'importing',
'perm': 'importing.list',
},
{
'title': "Luigi Tasks",
'route': 'luigi',
'perm': 'luigi.list',
},
{'type': 'sep'},
{
'title': "App Info",
'route': 'appinfo',
'perm': 'appinfo.list',
},
])
if kwargs.get('include_label_settings', False):
items.extend([
{
'title': "Label Settings",
'route': 'labelprofiles',
'perm': 'labelprofiles.list',
},
])
items.extend([
{
'title': "Raw Settings",
'route': 'settings',
'perm': 'settings.list',
},
{
'title': "Upgrades",
'route': 'upgrades',
'perm': 'upgrades.list',
},
])
return {
'title': "Admin",
'type': 'menu',
'items': items,
}
class MenuHandler(TailboneMenuHandler):
def __init__(self, *args, **kwargs):
warnings.warn("tailbone.menus.MenuHandler is deprecated; "
"please use tailbone.menus.TailboneMenuHandler instead",
DeprecationWarning, stacklevel=2)
super().__init__(*args, **kwargs)
class NullMenuHandler(WuttaMenuHandler):
"""
Null menu handler which uses an empty menu set.
.. note:
This class shouldn't even exist, but for the moment, it is
useful to configure non-traditional (e.g. API) web apps to use
this, in order to avoid most of the overhead.
"""
def make_menus(self, request, **kwargs):
return []

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar # Copyright © 2010-2018 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -27,33 +27,22 @@ Progress Indicator
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
import os import os
import warnings
from rattail.progress import ProgressBase from rattail.progress import ProgressBase
from beaker.session import Session from beaker.session import Session
def get_basic_session(config, request={}, **kwargs):
"""
Create/get a "basic" Beaker session object.
"""
kwargs['use_cookies'] = False
session = Session(request, **kwargs)
return session
def get_progress_session(request, key, **kwargs): def get_progress_session(request, key, **kwargs):
""" """
Create/get a Beaker session object, to be used for progress. Create/get a Beaker session object, to be used for progress.
""" """
kwargs['id'] = '{}.progress.{}'.format(request.session.id, key) id = '{}.progress.{}'.format(request.session.id, key)
kwargs['use_cookies'] = False
if kwargs.get('type') == 'file': if kwargs.get('type') == 'file':
warnings.warn("Passing a 'type' kwarg to get_progress_session() "
"is deprecated...i think",
DeprecationWarning, stacklevel=2)
kwargs['data_dir'] = os.path.join(request.rattail_config.appdir(), 'sessions') kwargs['data_dir'] = os.path.join(request.rattail_config.appdir(), 'sessions')
return get_basic_session(request.rattail_config, request, **kwargs) session = Session(request, id, **kwargs)
return session
class SessionProgress(ProgressBase): class SessionProgress(ProgressBase):
@ -63,20 +52,11 @@ class SessionProgress(ProgressBase):
This class is only responsible for keeping the progress *data* current. It This class is only responsible for keeping the progress *data* current. It
is the responsibility of some client-side AJAX (etc.) to consume the data is the responsibility of some client-side AJAX (etc.) to consume the data
for display to the user. for display to the user.
:param ws: If true, then websockets are assumed, and the progress will
behave accordingly. The default is false, "traditional" behavior.
""" """
def __init__(self, request, key, session_type=None, ws=False): def __init__(self, request, key, session_type=None):
self.key = key self.key = key
self.ws = ws self.session = get_progress_session(request, key, type=session_type)
if self.ws:
self.session = get_basic_session(request.rattail_config, id=key)
else:
self.session = get_progress_session(request, key, type=session_type)
self.canceled = False self.canceled = False
self.clear() self.clear()

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/>.
#
################################################################################
"""
Providers for Tailbone features
"""
from __future__ import unicode_literals, absolute_import
from rattail.util import load_entry_points
class TailboneProvider(object):
"""
Base class for Tailbone providers. These are responsible for
declaring which things a given project makes available to the app.
(Or at least the things which should be easily configurable.)
"""
def __init__(self, config):
self.config = config
def configure_db_sessions(self, rattail_config, pyramid_config):
pass
def get_static_includes(self):
pass
def get_provided_views(self):
return {}
def make_integration_menu(self, request, **kwargs):
pass
def get_all_providers(config):
"""
Returns a dict of all registered providers.
"""
providers = load_entry_points('tailbone.providers')
for key in list(providers):
providers[key] = providers[key](config)
return providers

View file

@ -111,7 +111,7 @@
<td class="brand">${cost.product.brand or ''}</td> <td class="brand">${cost.product.brand or ''}</td>
<td class="desc">${cost.product.description}</td> <td class="desc">${cost.product.description}</td>
<td class="size">${cost.product.size or ''}</td> <td class="size">${cost.product.size or ''}</td>
<td class="case-qty">${app.render_quantity(cost.case_size)} ${"LB" if cost.product.weighed else "EA"}</td> <td class="case-qty">${cost.case_size} ${"LB" if cost.product.weighed else "EA"}</td>
<td class="code">${cost.code or ''}</td> <td class="code">${cost.code or ''}</td>
<td class="preferred">${'X' if cost.preference == 1 else ''}</td> <td class="preferred">${'X' if cost.preference == 1 else ''}</td>
% for i in range(14): % for i in range(14):

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8 -*-
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -21,31 +21,25 @@
# #
################################################################################ ################################################################################
""" """
Tailbone Web API - Label Views Pyramid scaffold templates
""" """
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
from rattail.db.model import LabelProfile from rattail.files import resource_path
from rattail.util import prettify
from tailbone.api import APIMasterView from pyramid.scaffolds import PyramidTemplate
class LabelProfileView(APIMasterView): class RattailTemplate(PyramidTemplate):
""" _template_dir = resource_path('rattail:data/project')
API views for Label Profile data summary = "Starter project based on Rattail / Tailbone"
"""
model_class = LabelProfile
collection_url_prefix = '/label-profiles'
object_url_prefix = '/label-profile'
def pre(self, command, output_dir, vars):
def defaults(config, **kwargs): """
base = globals() Adds some more variables to the template context.
"""
LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView']) vars['project_title'] = prettify(vars['project'])
LabelProfileView.defaults(config) vars['package_title'] = vars['package'].capitalize()
return super(RattailTemplate, self).pre(command, output_dir, vars)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,8 +24,9 @@
Static Assets Static Assets
""" """
from __future__ import unicode_literals, absolute_import
def includeme(config): def includeme(config):
config.include('wuttaweb.static')
config.add_static_view('tailbone', 'tailbone:static') config.add_static_view('tailbone', 'tailbone:static')
config.add_static_view('deform', 'deform:static') config.add_static_view('deform', 'deform:static')

View file

@ -1,14 +1,115 @@
/******************************
* General
******************************/
* {
margin: 0px;
}
body {
font-family: Verdana, Arial, sans-serif;
font-size: 11pt;
}
a {
color: #0972a5;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1 {
margin-bottom: 15px;
}
h2 {
font-size: 12pt;
margin: 20px auto 10px auto;
}
li {
line-height: 2em;
}
p {
margin-bottom: 5px;
}
.left {
float: left;
text-align: left;
}
.right {
text-align: right;
}
.wrapper {
overflow: auto;
}
div.buttons {
clear: both;
margin-top: 10px;
}
div.dialog {
display: none;
}
div.flash-message {
background-color: #dddddd;
margin-bottom: 8px;
padding: 3px;
}
div.flash-messages div.ui-state-highlight {
padding: .3em;
margin-bottom: 8px;
}
div.error-messages div.ui-state-error {
padding: .3em;
margin-bottom: 8px;
}
.flash-messages,
.error-messages {
margin: 0.5em 0 0 0;
}
ul.error {
color: #dd6666;
font-weight: bold;
padding: 0px;
}
ul.error li {
list-style-type: none;
}
/******************************
* jQuery UI tweaks
******************************/
ul.ui-menu {
max-height: 30em;
}
/****************************** /******************************
* tweaks for root user * tweaks for root user
******************************/ ******************************/
.navbar .navbar-end .navbar-link.root-user, .menubar .root-user .ui-button-text,
.navbar .navbar-end .navbar-link.root-user:hover, .menubar .root-user.ui-menu-item a {
.navbar .navbar-end .navbar-link.root-user.is_active,
.navbar .navbar-end .navbar-item.root-user,
.navbar .navbar-end .navbar-item.root-user:hover,
.navbar .navbar-end .navbar-item.root-user.is_active {
background-color: red; background-color: red;
color: black;
font-weight: bold; font-weight: bold;
} }
.menubar .root-user.ui-menu-item a {
padding-left: 1em;
}

View file

@ -1,74 +0,0 @@
pre { line-height: 125%; }
td.linenos pre { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; }
span.linenos { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; }
td.linenos pre.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.codehilite .hll { background-color: #ffffcc }
.codehilite { background: #f8f8f8; }
.codehilite .c { color: #408080; font-style: italic } /* Comment */
.codehilite .err { border: 1px solid #FF0000 } /* Error */
.codehilite .k { color: #008000; font-weight: bold } /* Keyword */
.codehilite .o { color: #666666 } /* Operator */
.codehilite .ch { color: #408080; font-style: italic } /* Comment.Hashbang */
.codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */
.codehilite .cp { color: #BC7A00 } /* Comment.Preproc */
.codehilite .cpf { color: #408080; font-style: italic } /* Comment.PreprocFile */
.codehilite .c1 { color: #408080; font-style: italic } /* Comment.Single */
.codehilite .cs { color: #408080; font-style: italic } /* Comment.Special */
.codehilite .gd { color: #A00000 } /* Generic.Deleted */
.codehilite .ge { font-style: italic } /* Generic.Emph */
.codehilite .gr { color: #FF0000 } /* Generic.Error */
.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */
.codehilite .gi { color: #00A000 } /* Generic.Inserted */
.codehilite .go { color: #888888 } /* Generic.Output */
.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
.codehilite .gs { font-weight: bold } /* Generic.Strong */
.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
.codehilite .gt { color: #0044DD } /* Generic.Traceback */
.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
.codehilite .kp { color: #008000 } /* Keyword.Pseudo */
.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
.codehilite .kt { color: #B00040 } /* Keyword.Type */
.codehilite .m { color: #666666 } /* Literal.Number */
.codehilite .s { color: #BA2121 } /* Literal.String */
.codehilite .na { color: #7D9029 } /* Name.Attribute */
.codehilite .nb { color: #008000 } /* Name.Builtin */
.codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */
.codehilite .no { color: #880000 } /* Name.Constant */
.codehilite .nd { color: #AA22FF } /* Name.Decorator */
.codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */
.codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */
.codehilite .nf { color: #0000FF } /* Name.Function */
.codehilite .nl { color: #A0A000 } /* Name.Label */
.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */
.codehilite .nv { color: #19177C } /* Name.Variable */
.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
.codehilite .w { color: #bbbbbb } /* Text.Whitespace */
.codehilite .mb { color: #666666 } /* Literal.Number.Bin */
.codehilite .mf { color: #666666 } /* Literal.Number.Float */
.codehilite .mh { color: #666666 } /* Literal.Number.Hex */
.codehilite .mi { color: #666666 } /* Literal.Number.Integer */
.codehilite .mo { color: #666666 } /* Literal.Number.Oct */
.codehilite .sa { color: #BA2121 } /* Literal.String.Affix */
.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */
.codehilite .sc { color: #BA2121 } /* Literal.String.Char */
.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */
.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */
.codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */
.codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
.codehilite .sx { color: #008000 } /* Literal.String.Other */
.codehilite .sr { color: #BB6688 } /* Literal.String.Regex */
.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */
.codehilite .ss { color: #19177C } /* Literal.String.Symbol */
.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */
.codehilite .fm { color: #0000FF } /* Name.Function.Magic */
.codehilite .vc { color: #19177C } /* Name.Variable.Class */
.codehilite .vg { color: #19177C } /* Name.Variable.Global */
.codehilite .vi { color: #19177C } /* Name.Variable.Instance */
.codehilite .vm { color: #19177C } /* Name.Variable.Magic */
.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */

View file

@ -10,10 +10,6 @@ table.diff {
min-width: 80%; min-width: 80%;
} }
.ui-dialog-content table.diff {
color: black;
}
table.diff th, table.diff th,
table.diff td { table.diff td {
border-bottom: 1px solid Black; border-bottom: 1px solid Black;

View file

@ -1,18 +1,28 @@
/****************************** /******************************
* Grid Filters * Filters
******************************/ ******************************/
.filters .filter-fieldname .field, div.filters form {
.filters .filter-fieldname .field label { margin-bottom: 10px;
width: 100%;
} }
.filters .filter-fieldname .field label { div.filters div.filter {
justify-content: left; margin-bottom: 10px;
} }
.filters .filter-verb .select, div.filters div.filter label {
.filters .filter-verb .select select { margin-right: 8px;
width: 100%; }
div.filters div.filter select.filter-type {
margin-right: 8px;
}
div.filters div.filter div.value {
display: inline;
}
div.filters div.buttons * {
margin-right: 8px;
} }

View file

@ -1,37 +1,50 @@
/****************************** /******************************
* forms * Form Wrapper
******************************/ ******************************/
/* note that this should only apply to "normal" primary forms */ div.form-wrapper {
/* TODO: replace this with bulma equivalent */ overflow: auto;
}
/******************************
* Context Menu
******************************/
div.form-wrapper ul.context-menu {
float: right;
list-style-type: none;
margin: 0px;
text-align: right;
}
div.form-wrapper ul.context-menu li {
line-height: 2em;
}
/******************************
* Forms
******************************/
div.form,
div.fieldset-form,
div.fieldset {
clear: left;
float: left;
margin-top: 10px;
}
.form { .form {
padding-left: 5em; padding-left: 5em;
} }
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-label .label {
text-align: left;
white-space: nowrap;
width: 18em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-body {
min-width: 30em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-body .select,
.form-wrapper .form .field.is-horizontal .field-body .select select {
width: 100%;
}
/****************************** /******************************
* field-wrappers * Fieldsets
******************************/ ******************************/
/* TODO: replace this with bulma equivalent */
.field-wrapper { .field-wrapper {
clear: both; clear: both;
min-height: 30px; min-height: 30px;
@ -39,12 +52,16 @@
margin: 15px; margin: 15px;
} }
/* TODO: replace this with bulma equivalent */ .field-wrapper.with-error {
background-color: #ddcccc;
border: 2px solid #dd6666;
padding-bottom: 1em;
}
.field-wrapper .field-row { .field-wrapper .field-row {
display: table-row; display: table-row;
} }
/* TODO: replace this with bulma equivalent */
.field-wrapper label { .field-wrapper label {
display: table-cell; display: table-cell;
vertical-align: top; vertical-align: top;
@ -54,8 +71,58 @@
white-space: nowrap; white-space: nowrap;
} }
/* TODO: replace this with bulma equivalent */ .field-wrapper.with-error label {
padding-left: 1em;
}
.field-wrapper .field-error {
padding: 1em 0 0.5em 1em;
}
.field-wrapper .field-error .error-msg {
color: #dd6666;
font-weight: bold;
}
.field-wrapper .field { .field-wrapper .field {
display: table-cell; display: table-cell;
line-height: 25px; line-height: 25px;
} }
.field-wrapper .field input[type=text],
.field-wrapper .field input[type=password],
.field-wrapper .field select,
.field-wrapper .field textarea {
width: 320px;
}
label input[type="checkbox"],
label input[type="radio"] {
margin-right: 0.5em;
}
.field ul {
margin: 0px;
padding-left: 15px;
}
/******************************
* Buttons
******************************/
div.buttons {
clear: both;
margin: 10px 0px;
}
/******************************
* Email Profile forms
******************************/
.field-wrapper.description .field {
/* NOTE: This was added specifically for email settings (description), who
knows what else it breaks...hopefully nothing. */
width: 800px;
}

View file

@ -22,14 +22,10 @@
} }
.grid-wrapper .grid-header #context-menu { .grid-wrapper .grid-header #context-menu {
float: none;
margin: 0; margin: 0;
} }
.grid-tools {
display: flex;
gap: 0.5rem;
}
.grid-wrapper .grid-header td.tools { .grid-wrapper .grid-header td.tools {
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -266,10 +262,6 @@
* main actions * main actions
******************************/ ******************************/
a.grid-action {
white-space: nowrap;
}
.grid .actions { .grid .actions {
width: 1px; width: 1px;
} }

View file

@ -1,49 +0,0 @@
/********************************************************************************
* grids.rowstatus.css
*
* Add "row status" styles for grid tables.
********************************************************************************/
/**************************************************
* grid rows with "warning" status
**************************************************/
tr.warning {
background-color: #fcc;
}
/**************************************************
* grid rows with "notice" status
**************************************************/
tr.notice {
background-color: #fe8;
}
/**************************************************
* grid rows which are "checked" (selected)
**************************************************/
/* TODO: this references some color values, whereas it would be preferable
* to refer to some sort of "state" instead, color of which was
* configurable. b/c these are just the default Buefy theme colors. */
tr.is-checked {
background-color: #7957d5;
color: white;
}
tr.is-checked:hover {
color: #363636;
}
tr.is-checked a {
color: white;
}
tr.is-checked:hover a {
color: #7957d5;
}

View file

@ -0,0 +1,40 @@
.loadmask {
z-index: 100;
position: absolute;
top:0;
left:0;
-moz-opacity: 0.5;
opacity: .50;
filter: alpha(opacity=50);
background-color: #CCC;
width: 100%;
height: 100%;
zoom: 1;
}
.loadmask-msg {
z-index: 20001;
position: absolute;
top: 0;
left: 0;
border:1px solid #6593cf;
background: #c3daf9;
padding:2px;
}
.loadmask-msg div {
padding:5px 10px 5px 25px;
background: #fbfbfb url('../img/loading.gif') no-repeat 5px 5px;
line-height: 16px;
border:1px solid #a3bad9;
color:#222;
font:normal 11px tahoma, arial, helvetica, sans-serif;
cursor:wait;
}
.masked {
overflow: hidden !important;
}
.masked-relative {
position: relative !important;
}
.masked-hidden {
visibility: hidden !important;
}

View file

@ -0,0 +1,69 @@
ul.tagit {
padding: 1px 5px;
overflow: auto;
margin-left: inherit; /* usually we don't want the regular ul margins. */
margin-right: inherit;
}
ul.tagit li {
display: block;
float: left;
margin: 2px 5px 2px 0;
}
ul.tagit li.tagit-choice {
position: relative;
line-height: inherit;
}
input.tagit-hidden-field {
display: none;
}
ul.tagit li.tagit-choice-read-only {
padding: .2em .5em .2em .5em;
}
ul.tagit li.tagit-choice-editable {
padding: .2em 18px .2em .5em;
}
ul.tagit li.tagit-new {
padding: .25em 4px .25em 0;
}
ul.tagit li.tagit-choice a.tagit-label {
cursor: pointer;
text-decoration: none;
}
ul.tagit li.tagit-choice .tagit-close {
cursor: pointer;
position: absolute;
right: .1em;
top: 50%;
margin-top: -8px;
line-height: 17px;
}
/* used for some custom themes that don't need image icons */
ul.tagit li.tagit-choice .tagit-close .text-icon {
display: none;
}
ul.tagit li.tagit-choice input {
display: block;
float: left;
margin: 2px 5px 2px 0;
}
ul.tagit input[type="text"] {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-moz-box-shadow: none;
-webkit-box-shadow: none;
box-shadow: none;
border: none;
margin: 0;
padding: 0;
width: inherit;
background-color: inherit;
outline: none;
}

View file

@ -0,0 +1,15 @@
/*
* jQuery UI Menubar @VERSION
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*/
.ui-menubar { list-style: none; margin: 0; padding-left: 0; }
.ui-menubar-item { float: left; }
.ui-menubar .ui-button { float: left; font-weight: normal; border-top-width: 0 !important; border-bottom-width: 0 !important; margin: 0; outline: none; }
.ui-menubar .ui-menubar-link { border-right: 1px dashed transparent; border-left: 1px dashed transparent; }
.ui-menubar .ui-menu { width: 200px; position: absolute; z-index: 9999; font-weight: normal; }

View file

@ -0,0 +1,14 @@
/**********************************************************************
* jquery.ui.tailbone.css
*
* jQuery UI tweaks for Tailbone
**********************************************************************/
.ui-widget {
font-size: 1em;
}
.ui-menu-item a {
display: block;
}

View file

@ -0,0 +1,57 @@
/*
* Timepicker stylesheet
* Highly inspired from datepicker
* FG - Nov 2010 - Web3R
*
* version 0.0.3 : Fixed some settings, more dynamic
* version 0.0.4 : Removed width:100% on tables
* version 0.1.1 : set width 0 on tables to fix an ie6 bug
*/
.ui-timepicker-inline { display: inline; }
#ui-timepicker-div { padding: 0.2em; }
.ui-timepicker-table { display: inline-table; width: 0; }
.ui-timepicker-table table { margin:0.15em 0 0 0; border-collapse: collapse; }
.ui-timepicker-hours, .ui-timepicker-minutes { padding: 0.2em; }
.ui-timepicker-table .ui-timepicker-title { line-height: 1.8em; text-align: center; }
.ui-timepicker-table td { padding: 0.1em; width: 2.2em; }
.ui-timepicker-table th.periods { padding: 0.1em; width: 2.2em; }
/* span for disabled cells */
.ui-timepicker-table td span {
display:block;
padding:0.2em 0.3em 0.2em 0.5em;
width: 1.2em;
text-align:right;
text-decoration:none;
}
/* anchors for clickable cells */
.ui-timepicker-table td a {
display:block;
padding:0.2em 0.3em 0.2em 0.5em;
width: 1.2em;
cursor: pointer;
text-align:right;
text-decoration:none;
}
/* buttons and button pane styling */
.ui-timepicker .ui-timepicker-buttonpane {
background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0;
}
.ui-timepicker .ui-timepicker-buttonpane button { margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; }
/* The close button */
.ui-timepicker .ui-timepicker-close { float: right }
/* the now button */
.ui-timepicker .ui-timepicker-now { float: left; }
/* the deselect button */
.ui-timepicker .ui-timepicker-deselect { float: left; }

View file

@ -1,158 +1,243 @@
/****************************** /******************************
* main layout * Main Layout
******************************/ ******************************/
body { html, body, #body-wrapper {
display: flex; height: 100%;
flex-direction: column; }
min-height: 100vh;
body > #body-wrapper {
height: auto;
min-height: 100%;
}
#body-wrapper {
margin: 0 1em;
width: auto;
}
#header {
height: 50px;
line-height: 50px;
}
#body {
padding-top: 10px;
padding-bottom: 5em;
}
#footer {
clear: both;
margin-top: -4em;
text-align: center;
}
/******************************
* Header
******************************/
#header h1 {
float: left;
font-size: 25px;
margin: 0px;
}
#header h1.title {
font-size: 20px;
margin-left: 10px;
}
#header div.login {
float: right;
}
.global .title {
margin-left: 0.5em;
}
/* new stuff from 'better' theme begins here */
header .global {
background-color: #eaeaea;
height: 60px;
}
header .global a.home,
header .global a.global,
header .global span.global {
display: block;
float: left;
font-size: 2em;
font-weight: bold;
line-height: 60px;
margin-left: 10px;
}
header .global a.home img {
display: block;
float: left;
padding: 5px 5px 5px 30px;
}
header .global .grid-nav {
display: inline-block;
font-size: 16px;
font-weight: bold;
line-height: 60px;
margin-left: 5em;
}
header .global .grid-nav .ui-button,
header .global .grid-nav span.viewing {
margin-left: 1em;
}
header .global .feedback {
float: right;
line-height: 60px;
margin-right: 1em;
}
header .page {
border-bottom: 1px solid lightgrey;
padding: 0.5em;
}
header .page h1 {
margin: 0;
padding: 0 0 0 0.5em;
}
/******************************
* Logo
******************************/
#logo {
display: block;
margin: 40px auto;
}
/****************************************
* content
****************************************/
body > #body-wrapper {
margin: 0px;
position: relative;
} }
.content-wrapper { .content-wrapper {
display: flex; height: 100%;
flex: 1; padding-bottom: 30px;
flex-direction: column;
justify-content: space-between;
} }
#scrollpane {
/****************************** height: 100%;
* header
******************************/
/* this is the one in the very top left of screen, next to logo and linked to
the home page */
#global-header-title {
margin-left: 0.3rem;
} }
header .level { #scrollpane .inner-content {
/* TODO: not sure what this 60px was supposed to do? but it broke the */ padding: 0 0.5em 0.5em 0.5em;
/* styles for the feedback dialog, so disabled it is.
/* height: 60px; */
/* line-height: 60px; */
padding-left: 0.5em;
padding-right: 0.5em;
} }
header .level #header-logo {
display: inline-block;
}
header .level .global-title,
header .level-left .global-title {
font-size: 2em;
font-weight: bold;
}
/* indent nested menu items a bit */
header .navbar-item.nested {
padding-left: 2.5rem;
}
header span.header-text {
font-size: 2em;
font-weight: bold;
margin-right: 10px;
}
#content-title h1 {
margin-bottom: 0;
margin-right: 1rem;
max-width: 50%;
overflow: hidden;
padding: 0 0.3rem;
text-overflow: ellipsis;
white-space: nowrap;
}
/******************************
* content
******************************/
#page-body {
padding: 0.4em;
}
/****************************** /******************************
* context menu * context menu
******************************/ ******************************/
#context-menu { #context-menu {
margin-bottom: 1em; float: right;
margin-left: 1em; list-style-type: none;
margin: 0.5em;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
} }
/******************************
* "object helper" panel
******************************/
.object-helpers .panel {
margin: 1rem;
margin-bottom: 1.5rem;
}
.object-helpers .panel-heading {
white-space: nowrap;
}
.object-helpers a {
white-space: nowrap;
}
.object-helper {
border: 1px solid black;
margin: 1em;
padding: 1em;
width: 20em;
}
.object-helper-content {
margin-top: 1em;
}
/****************************** /******************************
* markdown * Panels
******************************/ ******************************/
.rendered-markdown p, .panel,
.rendered-markdown ul { .panel-grid {
margin-bottom: 1rem; border-left: 1px solid Black;
margin-bottom: 15px;
} }
.rendered-markdown .codehilite { .panel {
margin-bottom: 2rem; border-bottom: 1px solid Black;
border-right: 1px solid Black;
padding: 0px;
} }
/****************************** .panel h2,
* fix datepicker within modals .panel-grid h2 {
* TODO: someday this may not be necessary? cf. border-bottom: 1px solid Black;
* https://github.com/buefy/buefy/issues/292#issuecomment-347365637 border-top: 1px solid Black;
******************************/ padding: 5px;
margin: 0px;
.modal .animation-content .modal-card {
overflow: visible !important;
} }
.modal-card-body { .panel-grid h2 {
overflow: visible !important; border-right: 1px solid Black;
} }
/* TODO: a simpler option we might try sometime instead? */ .panel-body {
/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */ overflow: auto;
padding: 5px;
}
/* .dropdown-content{ */ /****************************************
/* position: fixed; */ * footer
/* } */ ****************************************/
#footer {
border-top: 1px solid lightgray;
bottom: 0;
font-size: 9pt;
height: 20px;
left: 0;
line-height: 20px;
margin: 0;
position: absolute;
width: 100%;
}
/****************************** /******************************
* feedback * feedback
******************************/ ******************************/
.feedback-dialog .red { #feedback-dialog {
display: none;
}
#feedback-dialog p {
margin-top: 1em;
}
#feedback-dialog .red {
color: red; color: red;
font-weight: bold; font-weight: bold;
} }
#feedback-dialog .field-wrapper {
margin-top: 1em;
padding: 0;
}
#feedback-dialog .field {
margin-bottom: 0;
margin-top: 0.5em;
}
#feedback-dialog .referrer .field {
clear: both;
float: none;
margin-top: 1em;
}
#feedback-dialog textarea {
width: auto;
}

View file

@ -0,0 +1,40 @@
/******************************
* login.css
******************************/
#logo {
margin: 40px auto;
}
div.form {
margin: auto;
float: none;
text-align: center;
}
div.field-wrapper {
margin: 10px auto;
width: 300px;
}
div.field-wrapper label {
text-align: right;
width: auto;
}
div.field-wrapper div.field input[type="text"],
div.field-wrapper div.field input[type="password"] {
margin-left: 1em;
width: 150px;
}
div.buttons input {
margin: auto 5px;
}
/* this is for "login as chuck" tip in demo mode */
.tips {
margin-top: 2em;
text-align: center;
}

View file

@ -0,0 +1,57 @@
/****************************************
* Global styles for mobile templates
****************************************/
/* main user menu button when root */
[data-role="header"] a.root-user,
[data-role="header"] a.root-user:hover {
background-color: red;
}
/* become/stop root menu links */
#usermenu .root-user a {
background-color: red;
}
/* normal flash messages */
.flash {
color: green;
margin-bottom: 1em;
}
/* error flash messages */
.error,
.error-messages {
color: red;
margin-bottom: 1em;
}
/* receiving warning flash messages */
.receiving-warning {
color: red;
}
.replacement-header {
display: none;
}
.field-wrapper.with-error {
background-color: #ddcccc;
border: 2px solid #dd6666;
margin-bottom: 1em;
}
.field-wrapper label {
font-weight: bold;
margin-top: 1em;
}
.field-error .error-msg {
color: Red;
}
/* make sure space comes between simple filter and "grid" list */
.simple-filter {
margin-bottom: 1.5em;
}

View file

@ -3,13 +3,12 @@
* Permission Lists * Permission Lists
******************************/ ******************************/
.permissions-group { .field-wrapper.permissions .field .group {
margin-bottom: 10px; margin-bottom: 10px;
} }
.permissions-group .group-label { .field-wrapper.permissions .field .group p {
font-weight: bold; font-weight: bold;
margin-bottom: 10px;
} }
.field-wrapper.permissions .field label { .field-wrapper.permissions .field label {
@ -22,12 +21,12 @@
margin-right: 10px; margin-right: 10px;
} }
.permissions-group .perm { .field-wrapper.permissions .field .group p.perm {
font-weight: normal; font-weight: normal;
margin-left: 15px; margin-left: 15px;
} }
.permissions-group p.perm span { .field-wrapper.permissions .field .group p.perm span {
font-family: monospace; font-family: monospace;
margin-right: 10px; margin-right: 10px;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before After
Before After

View file

@ -1,36 +0,0 @@
// this code was politely stolen from
// https://vanillajstoolkit.com/helpers/debounce/
// its purpose is to help with Buefy autocomplete performance
// https://buefy.org/documentation/autocomplete/
/**
* Debounce functions for better performance
* (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com
* @param {Function} fn The function to debounce
*/
function debounce (fn) {
// Setup a timer
let timeout;
// Return a function to run debounced
return function () {
// Setup the arguments
let context = this;
let args = arguments;
// If there's a timer, cancel it
if (timeout) {
window.cancelAnimationFrame(timeout);
}
// Setup the new requestAnimationFrame()
timeout = window.requestAnimationFrame(function () {
fn.apply(context, args);
});
};
}

452
tailbone/static/js/jquery.ui.tailbone.js vendored Normal file
View file

@ -0,0 +1,452 @@
/**********************************************************************
* jQuery UI plugins for Tailbone
**********************************************************************/
/**********************************************************************
* gridcore plugin
**********************************************************************/
(function($) {
$.widget('tailbone.gridcore', {
_create: function() {
var that = this;
// Add hover highlight effect to grid rows during mouse-over.
// this.element.on('mouseenter', 'tbody tr:not(.header)', function() {
this.element.on('mouseenter', 'tr:not(.header)', function() {
$(this).addClass('hovering');
});
// this.element.on('mouseleave', 'tbody tr:not(.header)', function() {
this.element.on('mouseleave', 'tr:not(.header)', function() {
$(this).removeClass('hovering');
});
// do some extra stuff for grids with checkboxes
// mark rows selected on page load, as needed
this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() {
$(this).parents('tr:first').addClass('selected');
});
// (un-)check all rows when clicking check-all box in header
if (this.element.find('tr.header td.checkbox :checkbox').length) {
this.element.on('click', 'tr.header td.checkbox :checkbox', function() {
var checked = $(this).prop('checked');
var rows = that.element.find('tr:not(.header)');
rows.find('td.checkbox :checkbox').prop('checked', checked);
if (checked) {
rows.addClass('selected');
} else {
rows.removeClass('selected');
}
that.element.trigger('gridchecked', that.count_selected());
});
}
// when row with checkbox is clicked, toggle selected status,
// unless clicking checkbox (since that already toggles it) or a
// link (since that does something completely different)
this.element.on('click', 'tr:not(.header)', function(event) {
var el = $(event.target);
if (!el.is('a') && !el.is(':checkbox')) {
$(this).find('td.checkbox :checkbox').click();
}
});
this.element.on('change', 'tr:not(.header) td.checkbox :checkbox', function() {
if (this.checked) {
$(this).parents('tr:first').addClass('selected');
} else {
$(this).parents('tr:first').removeClass('selected');
}
that.element.trigger('gridchecked', that.count_selected());
});
// Show 'more' actions when user hovers over 'more' link.
this.element.on('mouseenter', '.actions a.more', function() {
that.element.find('.actions div.more').hide();
$(this).siblings('div.more')
.show()
.position({my: 'left-5 top-4', at: 'left top', of: $(this)});
});
this.element.on('mouseleave', '.actions div.more', function() {
$(this).hide();
});
// Add speed bump for "Delete Row" action, if grid is so configured.
if (this.element.data('delete-speedbump')) {
this.element.on('click', 'tr:not(.header) .actions a.delete', function() {
return confirm("Are you sure you wish to delete this object?");
});
}
},
count_selected: function() {
return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').length;
},
// TODO: deprecate / remove this?
count_checked: function() {
return this.count_selected();
},
selected_rows: function() {
return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').parents('tr:first');
},
all_uuids: function() {
var uuids = [];
this.element.find('tr:not(.header)').each(function() {
uuids.push($(this).data('uuid'));
});
return uuids;
},
selected_uuids: function() {
var uuids = [];
this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() {
uuids.push($(this).parents('tr:first').data('uuid'));
});
return uuids;
}
});
})( jQuery );
/**********************************************************************
* gridwrapper plugin
**********************************************************************/
(function($) {
$.widget('tailbone.gridwrapper', {
_create: function() {
var that = this;
// Snag some element references.
this.filters = this.element.find('.newfilters');
this.filters_form = this.filters.find('form');
this.add_filter = this.filters.find('#add-filter');
this.apply_filters = this.filters.find('#apply-filters');
this.default_filters = this.filters.find('#default-filters');
this.clear_filters = this.filters.find('#clear-filters');
this.save_defaults = this.filters.find('#save-defaults');
this.grid = this.element.find('.grid');
// add standard grid behavior
this.grid.gridcore();
// Enhance filters etc.
this.filters.find('.filter').gridfilter();
this.apply_filters.button('option', 'icons', {primary: 'ui-icon-search'});
this.default_filters.button('option', 'icons', {primary: 'ui-icon-home'});
this.clear_filters.button('option', 'icons', {primary: 'ui-icon-trash'});
this.save_defaults.button('option', 'icons', {primary: 'ui-icon-disk'});
if (! this.filters.find('.active:checked').length) {
this.apply_filters.button('disable');
}
this.add_filter.selectmenu({
width: '15em',
// Initially disabled if contains no enabled filter options.
disabled: this.add_filter.find('option:enabled').length == 1,
// When add-filter choice is made, show/focus new filter value input,
// and maybe hide the add-filter selection or show the apply button.
change: function (event, ui) {
var filter = that.filters.find('#filter-' + ui.item.value);
var select = $(this);
var option = ui.item.element;
filter.gridfilter('active', true);
filter.gridfilter('focus');
select.val('');
option.attr('disabled', 'disabled');
select.selectmenu('refresh');
if (select.find('option:enabled').length == 1) { // prompt is always enabled
select.selectmenu('disable');
}
that.apply_filters.button('enable');
}
});
this.add_filter.on('selectmenuopen', function(event, ui) {
show_all_options($(this));
});
// Intercept filters form submittal, and submit via AJAX instead.
this.filters_form.on('submit', function() {
var settings = {filter: true, partial: true};
if (that.filters_form.find('input[name="save-current-filters-as-defaults"]').val() == 'true') {
settings['save-current-filters-as-defaults'] = true;
}
that.filters.find('.filter').each(function() {
// currently active filters will be included in form data
if ($(this).gridfilter('active')) {
settings[$(this).data('key')] = $(this).gridfilter('value');
settings[$(this).data('key') + '.verb'] = $(this).gridfilter('verb');
// others will be hidden from view
} else {
$(this).gridfilter('hide');
}
});
// if no filters are visible, disable submit button
if (! that.filters.find('.filter:visible').length) {
that.apply_filters.button('disable');
}
// okay, submit filters to server and refresh grid
that.refresh(settings);
return false;
});
// When user clicks Default Filters button, refresh page with
// instructions for the server to reset filters to default settings.
this.default_filters.click(function() {
that.filters_form.off('submit');
that.filters_form.find('input[name="reset-to-default-filters"]').val('true');
that.element.mask("Refreshing data...");
that.filters_form.get(0).submit();
});
// When user clicks Save Defaults button, refresh the grid as with
// Apply Filters, but add an instruction for the server to save
// current settings as defaults for the user.
this.save_defaults.click(function() {
that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('true');
that.filters_form.submit();
that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('false');
});
// When user clicks Clear Filters button, deactivate all filters
// and refresh the grid.
this.clear_filters.click(function() {
that.filters.find('.filter').each(function() {
if ($(this).gridfilter('active')) {
$(this).gridfilter('active', false);
}
});
that.filters_form.submit();
});
// Refresh data when user clicks a sortable column header.
this.element.on('click', 'tr.header a', function() {
var td = $(this).parent();
var data = {
sortkey: $(this).data('sortkey'),
sortdir: (td.hasClass('asc')) ? 'desc' : 'asc',
page: 1,
partial: true
};
that.refresh(data);
return false;
});
// Refresh data when user chooses a new page size setting.
this.element.on('change', '.pager #pagesize', function() {
var settings = {
partial: true,
pagesize: $(this).val()
};
that.refresh(settings);
});
// Refresh data when user clicks a pager link.
this.element.on('click', '.pager a', function() {
that.refresh(this.search.substring(1)); // remove leading '?'
return false;
});
},
// Refreshes the visible data within the grid, according to the given settings.
refresh: function(settings) {
var that = this;
this.element.mask("Refreshing data...");
$.get(this.grid.data('url'), settings, function(data) {
that.grid.replaceWith(data);
that.grid = that.element.find('.grid');
that.grid.gridcore();
that.element.unmask();
});
},
results_count: function(as_text) {
var count = null;
var match = /showing \d+ thru \d+ of (\S+)/.exec(this.element.find('.pager .showing').text());
if (match) {
count = match[1];
if (!as_text) {
count = parseInt(count, 10);
}
}
return count;
},
all_uuids: function() {
return this.grid.gridcore('all_uuids');
},
selected_uuids: function() {
return this.grid.gridcore('selected_uuids');
}
});
})( jQuery );
/**********************************************************************
* gridfilter plugin
**********************************************************************/
(function($) {
$.widget('tailbone.gridfilter', {
_create: function() {
var that = this;
// Track down some important elements.
this.checkbox = this.element.find('input[name$="-active"]');
this.label = this.element.find('label');
this.inputs = this.element.find('.inputs');
this.add_filter = this.element.parents('.grid-wrapper').find('#add-filter');
// Hide the checkbox and label, and add button for toggling active status.
this.checkbox.addClass('ui-helper-hidden-accessible');
this.label.hide();
this.activebutton = $('<button type="button" class="toggle" />')
.insertAfter(this.label)
.text(this.label.text())
.button({
icons: {primary: 'ui-icon-blank'}
});
// Enhance verb dropdown as selectmenu.
this.verb_select = this.inputs.find('.verb');
this.valueless_verbs = {};
$.each(this.verb_select.data('hide-value-for').split(' '), function(index, value) {
that.valueless_verbs[value] = true;
});
this.verb_select.selectmenu({
width: '15em',
change: function(event, ui) {
if (ui.item.value in that.valueless_verbs) {
that.inputs.find('.value').hide();
} else {
that.inputs.find('.value').show();
that.focus();
that.select();
}
}
});
this.verb_select.on('selectmenuopen', function(event, ui) {
show_all_options($(this));
});
// Enhance any date values with datepicker widget.
this.inputs.find('.value input[type="date"]').datepicker({
dateFormat: 'yy-mm-dd',
changeYear: true,
changeMonth: true
});
// Enhance any choice/dropdown values with selectmenu.
this.inputs.find('.value select').selectmenu({
// provide sane width for value dropdown
width: '15em'
});
this.inputs.find('.value select').on('selectmenuopen', function(event, ui) {
show_all_options($(this));
});
// Listen for button click, to keep checkbox in sync.
this._on(this.activebutton, {
click: function(e) {
var checked = !this.checkbox.is(':checked');
this.checkbox.prop('checked', checked);
this.refresh();
if (checked) {
this.focus();
}
}
});
// Update the initial state of the button according to checkbox.
this.refresh();
},
refresh: function() {
if (this.checkbox.is(':checked')) {
this.activebutton.button('option', 'icons', {primary: 'ui-icon-check'});
if (this.verb() in this.valueless_verbs) {
this.inputs.find('.value').hide();
} else {
this.inputs.find('.value').show();
}
this.inputs.show();
} else {
this.activebutton.button('option', 'icons', {primary: 'ui-icon-blank'});
this.inputs.hide();
}
},
active: function(value) {
if (value === undefined) {
return this.checkbox.is(':checked');
}
if (value) {
if (!this.checkbox.is(':checked')) {
this.checkbox.prop('checked', true);
this.refresh();
this.element.show();
}
} else if (this.checkbox.is(':checked')) {
this.checkbox.prop('checked', false);
this.refresh();
}
},
hide: function() {
this.active(false);
this.element.hide();
var option = this.add_filter.find('option[value="' + this.element.data('key') + '"]');
option.attr('disabled', false);
if (this.add_filter.selectmenu('option', 'disabled')) {
this.add_filter.selectmenu('enable');
}
this.add_filter.selectmenu('refresh');
},
focus: function() {
this.inputs.find('.value input').focus();
},
select: function() {
this.inputs.find('.value input').select();
},
value: function() {
return this.inputs.find('.value input, .value select').val();
},
verb: function() {
return this.inputs.find('.verb').val();
}
});
})( jQuery );

View file

@ -0,0 +1,81 @@
/******************************************
* jQuery Mobile plugins for Tailbone
*****************************************/
/******************************************
* mobile autocomplete
*****************************************/
(function($) {
$.widget('tailbone.mobileautocomplete', {
_create: function() {
var that = this;
// snag some element references
this.search = this.element.find('.ui-input-search');
this.hidden_field = this.element.find('input[type="hidden"]');
this.text_field = this.element.find('input[type="text"]');
this.ul = this.element.find('ul');
this.button = this.element.find('button');
// establish our autocomplete URL
this.url = this.options.url || this.element.data('url');
// NOTE: much of this code was copied from the jquery mobile demo site
// https://demos.jquerymobile.com/1.4.5/listview-autocomplete-remote/
this.ul.on('filterablebeforefilter', function(e, data) {
var $input = $( data.input ),
value = $input.val(),
html = "";
that.ul.html( "" );
if ( value && value.length > 2 ) {
that.ul.html( "<li><div class='ui-loader'><span class='ui-icon ui-icon-loading'></span></div></li>" );
that.ul.listview( "refresh" );
$.ajax({
url: that.url,
data: {
term: $input.val()
}
})
.then( function ( response ) {
$.each( response, function ( i, val ) {
html += '<li data-uuid="' + val.value + '">' + val.label + "</li>";
});
that.ul.html( html );
that.ul.listview( "refresh" );
that.ul.trigger( "updatelayout");
});
}
});
// when user clicks autocomplete result, hide search etc.
this.ul.on('click', 'li', function() {
var $li = $(this);
var uuid = $li.data('uuid');
that.search.hide();
that.hidden_field.val(uuid);
that.button.text($li.text()).show();
that.ul.hide();
that.element.trigger('autocompleteitemselected', uuid);
});
// when user clicks "change" button, show search etc.
this.button.click(function() {
that.button.hide();
that.ul.empty().show();
that.hidden_field.val('');
that.search.show();
that.text_field.focus();
that.element.trigger('autocompleteitemcleared');
});
}
});
})( jQuery );

View file

@ -0,0 +1,10 @@
/**
* Copyright (c) 2009 Sergiy Kovalchuk (serg472@gmail.com)
*
* Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php)
* and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses.
*
* Following code is based on Element.mask() implementation from ExtJS framework (http://extjs.com/)
*
*/
(function(a){a.fn.mask=function(c,b){a(this).each(function(){if(b!==undefined&&b>0){var d=a(this);d.data("_mask_timeout",setTimeout(function(){a.maskElement(d,c)},b))}else{a.maskElement(a(this),c)}})};a.fn.unmask=function(){a(this).each(function(){a.unmaskElement(a(this))})};a.fn.isMasked=function(){return this.hasClass("masked")};a.maskElement=function(d,c){if(d.data("_mask_timeout")!==undefined){clearTimeout(d.data("_mask_timeout"));d.removeData("_mask_timeout")}if(d.isMasked()){a.unmaskElement(d)}if(d.css("position")=="static"){d.addClass("masked-relative")}d.addClass("masked");var e=a('<div class="loadmask"></div>');if(navigator.userAgent.toLowerCase().indexOf("msie")>-1){e.height(d.height()+parseInt(d.css("padding-top"))+parseInt(d.css("padding-bottom")));e.width(d.width()+parseInt(d.css("padding-left"))+parseInt(d.css("padding-right")))}if(navigator.userAgent.toLowerCase().indexOf("msie 6")>-1){d.find("select").addClass("masked-hidden")}d.append(e);if(c!==undefined){var b=a('<div class="loadmask-msg" style="display:none;"></div>');b.append("<div>"+c+"</div>");d.append(b);b.css("top",Math.round(d.height()/2-(b.height()-parseInt(b.css("padding-top"))-parseInt(b.css("padding-bottom")))/2)+"px");b.css("left",Math.round(d.width()/2-(b.width()-parseInt(b.css("padding-left"))-parseInt(b.css("padding-right")))/2)+"px");b.show()}};a.unmaskElement=function(b){if(b.data("_mask_timeout")!==undefined){clearTimeout(b.data("_mask_timeout"));b.removeData("_mask_timeout")}b.find(".loadmask-msg,.loadmask").remove();b.removeClass("masked");b.removeClass("masked-relative");b.find("select").removeClass("masked-hidden")}})(jQuery);

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