Compare commits

..

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

397 changed files with 24532 additions and 35088 deletions

3
.gitignore vendored
View file

@ -1,8 +1,5 @@
*~
*.pyc
.coverage
.tox/
dist/
docs/_build/
htmlcov/
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.

File diff suppressed because it is too large Load diff

View file

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

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

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

View file

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

View file

@ -81,12 +81,6 @@ override when defining your subclass.
override this for certain views, if so that should be done within
:meth:`get_help_url()`.
.. attribute:: MasterView.version_diff_factory
Optional factory to use for version diff objects. By default
this is *not set* but a subclass is free to set it. See also
:meth:`get_version_diff_factory()`.
Methods to Override
-------------------
@ -94,8 +88,6 @@ Methods to Override
The following is a list of methods which you can override when defining your
subclass.
.. automethod:: MasterView.editable_instance
.. .. automethod:: MasterView.get_settings
.. automethod:: MasterView.get_csv_fields
@ -103,24 +95,3 @@ subclass.
.. automethod:: MasterView.get_csv_row
.. automethod:: MasterView.get_help_url
.. automethod:: MasterView.get_model_key
.. automethod:: MasterView.get_version_diff_enums
.. automethod:: MasterView.get_version_diff_factory
.. automethod:: MasterView.make_version_diff
.. automethod:: MasterView.title_for_version
Support Methods
---------------
The following is a list of methods you should (probably) not need to
override, but may find useful:
.. automethod:: MasterView.default_edit_url
.. automethod:: MasterView.get_action_route_kwargs

View file

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

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:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# Tailbone documentation build configuration file, created by
# sphinx-quickstart on Sat Feb 15 23:15:27 2014.
#
# This file is 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 -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import sys
import os
from importlib.metadata import version as get_version
import sphinx_rtd_theme
project = 'Tailbone'
copyright = '2010 - 2024, Lance Edgar'
author = 'Lance Edgar'
release = get_version('Tailbone')
exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read())
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.todo',
@ -23,30 +40,241 @@ extensions = [
'sphinx.ext.viewcode',
]
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {
'rattail': ('https://docs.wuttaproject.org/rattail/', None),
'rattail': ('https://rattailproject.org/docs/rattail/', None),
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
}
# allow todo entries to show up
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'Tailbone'
copyright = u'2010 - 2020, Lance Edgar'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
# version = '0.3'
version = '.'.join(__version__.split('.')[:2])
# The full version, including alpha/beta/rc tags.
release = __version__
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False
# Allow todo entries to show up.
todo_include_todos = True
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
# -- Options for HTML output ----------------------------------------------
html_theme = 'furo'
html_static_path = ['_static']
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
# html_theme = 'classic'
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
# of the sidebar.
#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.
#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

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

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

190
setup.py Normal file
View file

@ -0,0 +1,190 @@
# -*- 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/>.
#
################################################################################
"""
Setup script for Tailbone
"""
from __future__ import unicode_literals, absolute_import
import os.path
from setuptools import setup, find_packages
here = os.path.abspath(os.path.dirname(__file__))
exec(open(os.path.join(here, 'tailbone', '_version.py')).read())
README = open(os.path.join(here, 'README.rst')).read()
requires = [
#
# Version numbers within comments below have specific meanings.
# Basically the 'low' value is a "soft low," and 'high' a "soft high."
# In other words:
#
# If either a 'low' or 'high' value exists, the primary point to be
# made about the value is that it represents the most current (stable)
# version available for the package (assuming typical public access
# methods) whenever this project was started and/or documented.
# Therefore:
#
# If a 'low' version is present, you should know that attempts to use
# versions of the package significantly older than the 'low' version
# may not yield happy results. (A "hard" high limit may or may not be
# indicated by a true version requirement.)
#
# Similarly, if a 'high' version is present, and especially if this
# project has laid dormant for a while, you may need to refactor a bit
# when attempting to support a more recent version of the package. (A
# "hard" low limit should be indicated by a true version requirement
# when a 'high' version is present.)
#
# In any case, developers and other users are encouraged to play
# outside the lines with regard to these soft limits. If bugs are
# encountered then they should be filed as such.
#
# package # low high
# TODO: previously was capping this to pre-1.0 although i'm not sure why.
# however the 1.2 release has some breaking changes which require refactor.
# cf. https://pypi.org/project/zope.sqlalchemy/#id3
'zope.sqlalchemy<1.2', # 0.7 1.1
# TODO: apparently they jumped from 0.1 to 0.9 and that broke us...
# (0.1 was released on 2014-09-14 and then 0.9 came out on 2018-09-27)
# (i've cached 0.1 at pypi.rattailproject.org just in case it disappears)
# (still, probably a better idea is to refactor so we can use 0.9)
'webhelpers2_grid==0.1', # 0.1
# TODO: remove version cap once we can drop support for python 2.x
'cornice<5.0', # 3.4.2 4.0.1
# TODO: remove once their bug is fixed? idk what this is about yet...
'deform<2.0.15', # 2.0.14
# TODO: cornice<5 requires pyramid<2 (see above)
'pyramid<2', # 1.3b2 1.10.8
'asgiref', # 3.2.3
'colander', # 1.7.0
'ColanderAlchemy', # 0.3.3
'humanize', # 0.5.1
'Mako', # 0.6.2
'markdown', # 3.3.3
'openpyxl', # 2.4.7
'paginate', # 0.5.6
'paginate_sqlalchemy', # 0.2.0
'passlib', # 1.7.1
'Pillow', # 5.3.0
'pyramid_beaker>=0.6', # 0.6.1
'pyramid_deform', # 0.2
'pyramid_exclog', # 0.6
'pyramid_mako', # 1.0.2
'pyramid_tm', # 0.3
'rattail[db,bouncer]', # 0.5.0
'six', # 1.10.0
'sqlalchemy-filters', # 0.8.0
'transaction', # 1.2.0
'waitress', # 0.8.1
'WebHelpers2', # 2.0
'WTForms', # 2.1
]
extras = {
'docs': [
#
# package # low high
# TODO: remove version workaround after next sphinx[-rtd-theme] release
# cf. https://github.com/readthedocs/sphinx_rtd_theme/issues/1343
'Sphinx!=5.2.0.post0', # 1.2
'sphinx-rtd-theme', # 0.2.4
],
'tests': [
#
# package # low high
'coverage', # 3.6
'fixture', # 1.5
'mock', # 1.0.1
'nose', # 1.3.0
'pytest', # 4.6.11
'pytest-cov', # 2.12.1
],
}
setup(
name = "Tailbone",
version = __version__,
author = "Lance Edgar",
author_email = "lance@edbob.org",
url = "http://rattailproject.org/",
license = "GNU GPL v3",
description = "Backoffice Web Application for Rattail",
long_description = README,
classifiers = [
'Development Status :: 4 - Beta',
'Environment :: Web Environment',
'Framework :: Pyramid',
'Intended Audience :: Developers',
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
'Natural Language :: English',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Office/Business',
'Topic :: Software Development :: Libraries :: Python Modules',
],
install_requires = requires,
extras_require = extras,
tests_require = ['Tailbone[tests]'],
test_suite = 'nose.collector',
packages = find_packages(exclude=['tests.*', 'tests']),
include_package_data = True,
zip_safe = False,
entry_points = {
'paste.app_factory': [
'main = tailbone.app:main',
'webapi = tailbone.webapi:main',
],
'rattail.config.extensions': [
'tailbone = tailbone.config:ConfigExtension',
],
'pyramid.scaffold': [
'rattail = tailbone.scaffolds:RattailTemplate',
],
},
)

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Auth Views
"""
from __future__ import unicode_literals, absolute_import
from rattail.db.auth import set_user_password
from cornice import Service
from tailbone.api import APIView, api
@ -40,10 +44,11 @@ class AuthenticationView(APIView):
This will establish a server-side web session for the user if none
exists. Note that this also resets the user's session timer.
"""
data = {'ok': True, 'permissions': []}
data = {'ok': True}
if self.request.user:
data['user'] = self.get_user_info(self.request.user)
data['permissions'] = list(self.request.user_permissions)
data['permissions'] = list(self.request.tailbone_cached_permissions)
# background color may be set per-request, by some apps
if hasattr(self.request, 'background_color') and self.request.background_color:
@ -163,9 +168,6 @@ class AuthenticationView(APIView):
if not self.request.user:
raise self.forbidden()
if self.request.user.prevent_password_change and not self.request.is_root:
raise self.forbidden()
data = self.request.json_body
# first make sure "current" password is accurate
@ -173,8 +175,7 @@ class AuthenticationView(APIView):
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'])
set_user_password(self.request.user, data['new_password'])
return {
'ok': True,
'user': self.get_user_info(self.request.user),

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,9 +24,13 @@
Tailbone Web API - Batch Views
"""
from __future__ import unicode_literals, absolute_import
import logging
import warnings
import six
from cornice import Service
from tailbone.api import APIMasterView
@ -66,7 +70,9 @@ class APIBatchMixin(object):
"""
app = self.get_rattail_app()
key = self.get_batch_class().batch_key
return app.get_batch_handler(key, default=self.default_handler_spec)
spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key),
default=self.default_handler_spec)
return app.load_object(spec)(self.rattail_config)
class APIBatchView(APIBatchMixin, APIMasterView):
@ -98,25 +104,25 @@ class APIBatchView(APIBatchMixin, APIMasterView):
return {
'uuid': batch.uuid,
'_str': str(batch),
'_str': six.text_type(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': six.text_type(created),
'created_display': self.pretty_datetime(created),
'created_by_uuid': batch.created_by.uuid,
'created_by_display': str(batch.created_by),
'created_by_display': six.text_type(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,
six.text_type(batch.status_code)),
'executed': six.text_type(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 ''),
'executed_by_display': six.text_type(batch.executed_by or ''),
'mutable': self.batch_handler.is_mutable(batch),
}
@ -267,8 +273,8 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
batch = row.batch
return {
'uuid': row.uuid,
'_str': str(row),
'_parent_str': str(batch),
'_str': six.text_type(row),
'_parent_str': six.text_type(batch),
'_parent_uuid': batch.uuid,
'batch_uuid': batch.uuid,
'batch_id': batch.id,
@ -279,7 +285,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
'batch_mutable': self.batch_handler.is_mutable(batch),
'sequence': row.sequence,
'status_code': row.status_code,
'status_display': row.STATUS.get(row.status_code, str(row.status_code)),
'status_display': row.STATUS.get(row.status_code, six.text_type(row.status_code)),
}
def update_object(self, row, data):
@ -314,7 +320,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
data = self.request.json_body
uuid = data['batch_uuid']
batch = self.Session.get(self.get_batch_class(), uuid)
batch = self.Session.query(self.get_batch_class()).get(uuid)
if not batch:
raise self.notfound()
@ -326,14 +332,11 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
log.warning("quick entry failed for '%s' batch %s: %s",
self.batch_handler.batch_key, batch.id_str, entry,
exc_info=True)
msg = str(error)
msg = six.text_type(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
@ -349,12 +352,13 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
collection_url_prefix = cls.get_collection_url_prefix()
object_url_prefix = cls.get_object_url_prefix()
if cls.supports_quick_entry:
# quick entry
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)
config.add_route('{}.quick_entry'.format(route_prefix), '{}/quick-entry'.format(collection_url_prefix),
request_method=('OPTIONS', 'POST'))
config.add_view(cls, attr='quick_entry', route_name='{}.quick_entry'.format(route_prefix),
permission='{}.edit'.format(permission_prefix),
renderer='json')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,12 +24,15 @@
Tailbone Web API - Inventory Batches
"""
from __future__ import unicode_literals, absolute_import
import decimal
import sqlalchemy as sa
import six
from rattail import pod
from rattail.db.model import InventoryBatch, InventoryBatchRow
from rattail.db import model
from rattail.util import pretty_quantity
from cornice import Service
@ -38,7 +41,7 @@ from tailbone.api.batch import APIBatchView, APIBatchRowView
class InventoryBatchViews(APIBatchView):
model_class = InventoryBatch
model_class = model.InventoryBatch
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
route_prefix = 'inventory'
permission_prefix = 'batch.inventory'
@ -47,12 +50,12 @@ class InventoryBatchViews(APIBatchView):
supports_toggle_complete = True
def normalize(self, batch):
data = super().normalize(batch)
data = super(InventoryBatchViews, self).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['mode_display'] = six.text_type(batch.mode)
data['reason_code'] = batch.reason_code
@ -116,7 +119,7 @@ class InventoryBatchViews(APIBatchView):
class InventoryBatchRowViews(APIBatchRowView):
model_class = InventoryBatchRow
model_class = model.InventoryBatchRow
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
route_prefix = 'inventory.rows'
permission_prefix = 'batch.inventory'
@ -127,24 +130,23 @@ class InventoryBatchRowViews(APIBatchRowView):
def normalize(self, row):
batch = row.batch
data = super().normalize(row)
app = self.get_rattail_app()
data = super(InventoryBatchRowViews, self).normalize(row)
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc'] = six.text_type(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['case_quantity'] = pretty_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),
pretty_quantity(row.cases or row.units),
'CS' if row.cases else data['unit_uom'])
data['allow_cases'] = self.batch_handler.allow_cases(batch)
@ -172,17 +174,7 @@ class InventoryBatchRowViews(APIBatchRowView):
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
row = super(InventoryBatchRowViews, self).update_object(row, data)
return row

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Label Batches
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api.batch import APIBatchView, APIBatchRowView
@ -52,10 +56,10 @@ class LabelBatchRowViews(APIBatchRowView):
def normalize(self, row):
batch = row.batch
data = super().normalize(row)
data = super(LabelBatchRowViews, self).normalize(row)
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc'] = six.text_type(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -27,24 +27,21 @@ These views expose the basic CRUD interface to "ordering" batches, for the web
API.
"""
import datetime
import logging
from __future__ import unicode_literals, absolute_import
import sqlalchemy as sa
import six
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
from rattail.db import model
from rattail.util import pretty_quantity
from cornice import Service
from tailbone.api.batch import APIBatchView, APIBatchRowView
log = logging.getLogger(__name__)
class OrderingBatchViews(APIBatchView):
model_class = PurchaseBatch
model_class = model.PurchaseBatch
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'orderingbatchviews'
permission_prefix = 'ordering'
@ -60,19 +57,18 @@ class OrderingBatchViews(APIBatchView):
Adds a condition to the query, to ensure only purchase batches with
"ordering" mode are returned.
"""
model = self.model
query = super().base_query()
query = super(OrderingBatchViews, self).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 = super(OrderingBatchViews, self).normalize(batch)
data['vendor_uuid'] = batch.vendor.uuid
data['vendor_display'] = str(batch.vendor)
data['vendor_display'] = six.text_type(batch.vendor)
data['department_uuid'] = batch.department_uuid
data['department_display'] = str(batch.department) if batch.department else None
data['department_display'] = six.text_type(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
@ -86,10 +82,8 @@ class OrderingBatchViews(APIBatchView):
Sets the mode to "ordering" for the new batch.
"""
data = dict(data)
if not data.get('vendor_uuid'):
raise ValueError("You must specify the vendor")
data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
batch = super().create_object(data)
batch = super(OrderingBatchViews, self).create_object(data)
return batch
def worksheet(self):
@ -100,8 +94,6 @@ class OrderingBatchViews(APIBatchView):
if batch.executed:
raise self.forbidden()
app = self.get_rattail_app()
# TODO: much of the logic below was copied from the traditional master
# view for ordering batches. should maybe let them share it somehow?
@ -156,7 +148,7 @@ class OrderingBatchViews(APIBatchView):
product = cost.product
subdept_costs.append({
'uuid': cost.uuid,
'upc': str(product.upc),
'upc': six.text_type(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,
@ -177,8 +169,8 @@ class OrderingBatchViews(APIBatchView):
# sort the (sub)department groupings
sorted_departments = []
for dept in sorted(departments.values(), key=lambda d: d['name']):
dept['subdepartments'] = sorted(dept['subdepartments'].values(),
for dept in sorted(six.itervalues(departments), key=lambda d: d['name']):
dept['subdepartments'] = sorted(six.itervalues(dept['subdepartments']),
key=lambda s: s['name'])
sorted_departments.append(dept)
@ -187,18 +179,6 @@ class OrderingBatchViews(APIBatchView):
for i in range(6 - len(history)):
history.append(None)
history = list(reversed(history))
# must convert some date objects to string, for JSON sake
for h in history:
if not h:
continue
purchase = h.get('purchase')
if purchase:
dt = purchase.get('date_ordered')
if dt and isinstance(dt, datetime.date):
purchase['date_ordered'] = app.render_date(dt)
dt = purchase.get('date_received')
if dt and isinstance(dt, datetime.date):
purchase['date_received'] = app.render_date(dt)
return {
'batch': self.normalize(batch),
@ -229,7 +209,7 @@ class OrderingBatchViews(APIBatchView):
class OrderingBatchRowViews(APIBatchRowView):
model_class = PurchaseBatchRow
model_class = model.PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'ordering.rows'
permission_prefix = 'ordering'
@ -239,12 +219,11 @@ class OrderingBatchRowViews(APIBatchRowView):
editable = True
def normalize(self, row):
data = super().normalize(row)
app = self.get_rattail_app()
batch = row.batch
data = super(OrderingBatchRowViews, self).normalize(row)
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc'] = six.text_type(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description
@ -261,15 +240,15 @@ class OrderingBatchRowViews(APIBatchRowView):
data['case_quantity'] = row.case_quantity
data['cases_ordered'] = row.cases_ordered
data['units_ordered'] = row.units_ordered
data['cases_ordered_display'] = 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['cases_ordered_display'] = pretty_quantity(row.cases_ordered or 0, empty_zero=False)
data['units_ordered_display'] = pretty_quantity(row.units_ordered or 0, empty_zero=False)
data['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))
data['status_display'] = row.STATUS.get(row.status_code, six.text_type(row.status_code))
return data
@ -290,17 +269,7 @@ class OrderingBatchRowViews(APIBatchRowView):
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}
self.batch_handler.update_row_quantity(row, **data)
return row

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,14 +24,16 @@
Tailbone Web API - Receiving Batches
"""
from __future__ import unicode_literals, absolute_import
import logging
import six
import humanize
import sqlalchemy as sa
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
from rattail.db import model
from rattail.util import pretty_quantity
from cornice import Service
from deform import widget as dfwidget
from tailbone import forms
@ -44,7 +46,7 @@ log = logging.getLogger(__name__)
class ReceivingBatchViews(APIBatchView):
model_class = PurchaseBatch
model_class = model.PurchaseBatch
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receivingbatchviews'
permission_prefix = 'receiving'
@ -54,21 +56,19 @@ class ReceivingBatchViews(APIBatchView):
supports_execute = True
def base_query(self):
model = self.app.model
query = super().base_query()
query = super(ReceivingBatchViews, self).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 = super(ReceivingBatchViews, self).normalize(batch)
data['vendor_uuid'] = batch.vendor.uuid
data['vendor_display'] = str(batch.vendor)
data['vendor_display'] = six.text_type(batch.vendor)
data['department_uuid'] = batch.department_uuid
data['department_display'] = str(batch.department) if batch.department else None
data['department_display'] = six.text_type(batch.department) if batch.department else None
data['po_number'] = batch.po_number
data['po_total'] = batch.po_total
data['invoice_total'] = batch.invoice_total
data['invoice_total_calculated'] = batch.invoice_total_calculated
@ -79,15 +79,9 @@ class ReceivingBatchViews(APIBatchView):
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)
batch = super(ReceivingBatchViews, self).create_object(data)
return batch
def auto_receive(self):
"""
@ -120,9 +114,8 @@ class ReceivingBatchViews(APIBatchView):
return self._get(obj=batch)
def eligible_purchases(self):
model = self.app.model
uuid = self.request.params.get('vendor_uuid')
vendor = self.Session.get(model.Vendor, uuid) if uuid else None
vendor = self.Session.query(model.Vendor).get(uuid) if uuid else None
if not vendor:
return {'error': "Vendor not found"}
@ -153,31 +146,31 @@ class ReceivingBatchViews(APIBatchView):
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)
# auto-receive
config.add_route('{}.auto_receive'.format(route_prefix),
'{}/{{uuid}}/auto-receive'.format(object_url_prefix))
config.add_view(cls, attr='auto_receive',
route_name='{}.auto_receive'.format(route_prefix),
permission='{}.auto_receive'.format(permission_prefix),
renderer='json')
# 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)
# mark receiving complete
config.add_route('{}.mark_receiving_complete'.format(route_prefix), '{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix))
config.add_view(cls, attr='mark_receiving_complete', route_name='{}.mark_receiving_complete'.format(route_prefix),
permission='{}.edit'.format(permission_prefix),
renderer='json')
# 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)
config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(collection_url_prefix),
request_method='GET')
config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix),
permission='{}.create'.format(permission_prefix),
renderer='json')
class ReceivingBatchRowViews(APIBatchRowView):
model_class = PurchaseBatchRow
model_class = model.PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receiving.rows'
permission_prefix = 'receiving'
@ -186,8 +179,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
supports_quick_entry = True
def make_filter_spec(self):
model = self.app.model
filters = super().make_filter_spec()
filters = super(ReceivingBatchRowViews, self).make_filter_spec()
if filters:
# must translate certain convenience filters
@ -283,30 +275,21 @@ class ReceivingBatchRowViews(APIBatchRowView):
]},
])
# is_missing
elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True:
filters.extend([
{'or': [
{'field': 'cases_missing', 'op': '!=', 'value': 0},
{'field': 'units_missing', 'op': '!=', 'value': 0},
]},
])
else: # just some filter, use as-is
filters.append(filtr)
return filters
def normalize(self, row):
data = super().normalize(row)
model = self.app.model
data = super(ReceivingBatchRowViews, self).normalize(row)
batch = row.batch
prodder = self.app.get_products_handler()
app = self.get_rattail_app()
prodder = app.get_products_handler()
data['product_uuid'] = row.product_uuid
data['item_id'] = row.item_id
data['upc'] = str(row.upc)
data['upc'] = six.text_type(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description
@ -338,9 +321,6 @@ class ReceivingBatchRowViews(APIBatchRowView):
data['cases_expired'] = row.cases_expired
data['units_expired'] = row.units_expired
data['cases_missing'] = row.cases_missing
data['units_missing'] = row.units_missing
cases, units = self.batch_handler.get_unconfirmed_counts(row)
data['cases_unconfirmed'] = cases
data['units_unconfirmed'] = units
@ -348,7 +328,6 @@ class ReceivingBatchRowViews(APIBatchRowView):
data['po_unit_cost'] = row.po_unit_cost
data['po_total'] = row.po_total
data['invoice_number'] = row.invoice_number
data['invoice_unit_cost'] = row.invoice_unit_cost
data['invoice_total'] = row.invoice_total
data['invoice_total_calculated'] = row.invoice_total_calculated
@ -377,7 +356,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
if accounted_for:
# some product accounted for; button should receive "remainder" only
if remainder:
remainder = self.app.render_quantity(remainder)
remainder = pretty_quantity(remainder)
data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive Remainder ({} {})".format(
remainder, data['unit_uom'])
@ -388,7 +367,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
else: # nothing yet accounted for, button should receive "all"
if not remainder:
log.warning("quick receive remainder is empty for row %s", row.uuid)
remainder = self.app.render_quantity(remainder)
remainder = pretty_quantity(remainder)
data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive ALL ({} {})".format(
remainder, data['unit_uom'])
@ -416,7 +395,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
data['received_alert'] = None
if self.batch_handler.get_units_confirmed(row):
msg = "You have already received some of this product; last update was {}.".format(
humanize.naturaltime(self.app.make_utc() - row.modified))
humanize.naturaltime(app.make_utc() - row.modified))
data['received_alert'] = msg
return data
@ -425,37 +404,27 @@ class ReceivingBatchRowViews(APIBatchRowView):
"""
View which handles "receiving" against a particular batch row.
"""
model = self.app.model
# first do basic input validation
schema = ReceiveRow().bind(session=self.Session())
form = forms.Form(schema=schema, request=self.request)
# TODO: this seems hacky, but avoids "complex" date value parsing
form.set_widget('expiration_date', dfwidget.TextInputWidget())
if not form.validate():
log.warning("form did not validate: %s",
form.make_deform_form().error)
if not form.validate(newstyle=True):
log.debug("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'])
row = self.Session.query(model.PurchaseBatchRow).get(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}
self.batch_handler.receive_row(row, **kwargs)
self.Session.flush()
return self._get(obj=row)
@classmethod
@ -471,11 +440,11 @@ class ReceivingBatchRowViews(APIBatchRowView):
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)
config.add_route('{}.receive'.format(route_prefix), '{}/{{uuid}}/receive'.format(object_url_prefix),
request_method=('OPTIONS', 'POST'))
config.add_view(cls, attr='receive', route_name='{}.receive'.format(route_prefix),
permission='{}.edit_row'.format(permission_prefix),
renderer='json')
def defaults(config, **kwargs):

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,14 +24,16 @@
Tailbone Web API - "Common" Views
"""
from collections import OrderedDict
from __future__ import unicode_literals, absolute_import
from rattail.util import get_pkg_version
import rattail
from rattail.db import model
from rattail.mail import send_email
from rattail.util import OrderedDict
from cornice import Service
from cornice.service import get_services
from cornice_swagger import CorniceSwagger
import tailbone
from tailbone import forms
from tailbone.forms.common import Feedback
from tailbone.api import APIView, api
@ -63,12 +65,11 @@ class CommonView(APIView):
}
def get_project_title(self):
app = self.get_rattail_app()
return app.get_title()
return self.rattail_config.app_title(default="Tailbone")
def get_project_version(self):
app = self.get_rattail_app()
return app.get_version()
import tailbone
return tailbone.__version__
def get_packages(self):
"""
@ -76,8 +77,8 @@ class CommonView(APIView):
'about' page.
"""
return OrderedDict([
('rattail', get_pkg_version('rattail')),
('Tailbone', get_pkg_version('Tailbone')),
('rattail', rattail.__version__),
('Tailbone', tailbone.__version__),
])
@api
@ -85,20 +86,18 @@ class CommonView(APIView):
"""
View to handle user feedback form submits.
"""
app = self.get_rattail_app()
model = self.model
# TODO: this logic was copied from tailbone.views.common and is largely
# identical; perhaps should merge somehow?
schema = Feedback().bind(session=Session())
form = forms.Form(schema=schema, request=self.request)
if form.validate():
if form.validate(newstyle=True):
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'])
data['user'] = Session.query(model.User).get(data['user'])
# TODO: should provide URL to view user
if data['user']:
@ -106,27 +105,17 @@ class CommonView(APIView):
data['client_ip'] = self.request.client_addr
email_key = data['email_key'] or self.feedback_email_key
app.send_email(email_key, data=data)
send_email(self.rattail_config, 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')
@ -139,14 +128,6 @@ class CommonView(APIView):
permission='common.feedback')
config.add_cornice_service(feedback)
# swagger
swagger = Service(name='swagger',
path='/swagger.json',
description=f"OpenAPI documentation for {app.get_title()}")
swagger.add_view('GET', 'swagger', klass=cls,
permission='common.api_swagger')
config.add_cornice_service(swagger)
def defaults(config, **kwargs):
base = globals()

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,8 @@
Tailbone Web API - Core Views
"""
from __future__ import unicode_literals, absolute_import
from tailbone.views import View
@ -99,20 +101,20 @@ class APIView(View):
return info
"""
app = self.get_rattail_app()
auth = app.get_auth_handler()
auth_handler = app.get_auth_handler()
# basic / default info
is_admin = auth.user_is_admin(user)
employee = app.get_employee(user)
is_admin = user.is_admin()
employee = user.employee
info = {
'uuid': user.uuid,
'username': user.username,
'display_name': user.display_name,
'short_name': auth.get_short_display_name(user),
'short_name': user.get_short_name(),
'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),
'email_address': auth_handler.get_email_address(user),
}
# maybe get/use "extra" info

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Customer Views
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api import APIMasterView
@ -42,7 +46,7 @@ class CustomerView(APIMasterView):
def normalize(self, customer):
return {
'uuid': customer.uuid,
'_str': str(customer),
'_str': six.text_type(customer),
'id': customer.id,
'number': customer.number,
'name': customer.name,

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

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,15 +24,26 @@
Tailbone Web API - Master View
"""
from __future__ import unicode_literals, absolute_import
import json
import six
from rattail.config import parse_bool
from rattail.db.util import get_fieldnames
from cornice import resource, Service
from tailbone.api import APIView
from tailbone.api import APIView, api
from tailbone.db import Session
from tailbone.util import SortColumn
class SortColumn(object):
def __init__(self, field_name, model_name=None):
self.field_name = field_name
self.model_name = model_name
class APIMasterView(APIView):
@ -184,7 +195,7 @@ class APIMasterView(APIView):
if sortcol:
spec = {
'field': sortcol.field_name,
'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc',
}
if sortcol.model_name:
spec['model'] = sortcol.model_name
@ -268,7 +279,7 @@ class APIMasterView(APIView):
return self._fieldnames
def normalize(self, obj):
data = {'_str': str(obj)}
data = {'_str': six.text_type(obj)}
for field in self.get_fieldnames():
data[field] = getattr(obj, field)
@ -276,7 +287,7 @@ class APIMasterView(APIView):
return data
def _collection_get(self):
from sa_filters import apply_filters, apply_sort, apply_pagination
from sqlalchemy_filters import apply_filters, apply_sort, apply_pagination
query = self.base_query()
context = {}
@ -332,7 +343,7 @@ class APIMasterView(APIView):
if not uuid:
uuid = self.request.matchdict['uuid']
obj = self.Session.get(self.get_model_class(), uuid)
obj = self.Session.query(self.get_model_class()).get(uuid)
if obj:
return obj
@ -354,13 +365,9 @@ class APIMasterView(APIView):
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)
obj = self.create_object(data)
self.Session.flush()
return self._get(obj)
def create_object(self, data):
"""
@ -387,7 +394,7 @@ class APIMasterView(APIView):
"""
if not uuid:
uuid = self.request.matchdict['uuid']
obj = self.Session.get(self.get_model_class(), uuid)
obj = self.Session.query(self.get_model_class()).get(uuid)
if not obj:
raise self.notfound()
@ -472,16 +479,13 @@ class APIMasterView(APIView):
"""
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)
filename = self.request.GET.get('filename', None)
if not filename:
raise self.notfound()
path = self.download_path(obj, filename)
return self.rawbytes_response(obj)
def rawbytes_response(self, obj):
raise NotImplementedError
response = self.file_response(path, attachment=False)
return response
##############################
# autocomplete

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Person Views
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api import APIMasterView
@ -41,7 +45,7 @@ class PersonView(APIMasterView):
def normalize(self, person):
return {
'uuid': person.uuid,
'_str': str(person),
'_str': six.text_type(person),
'first_name': person.first_name,
'last_name': person.last_name,
'display_name': person.display_name,

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,21 +24,17 @@
Tailbone Web API - Product Views
"""
import logging
from __future__ import unicode_literals, absolute_import
import six
import sqlalchemy as sa
from sqlalchemy import orm
from cornice import Service
from rattail.db import model
from tailbone.api import APIMasterView
log = logging.getLogger(__name__)
class ProductView(APIMasterView):
"""
API views for Product data
@ -48,49 +44,20 @@ class ProductView(APIMasterView):
object_url_prefix = '/product'
supports_autocomplete = True
def __init__(self, request, context=None):
super(ProductView, self).__init__(request, context=context)
app = self.get_rattail_app()
self.products_handler = app.get_products_handler()
def normalize(self, product):
# get what we can from handler
data = self.products_handler.normalize_product(product, fields=[
'brand_name',
'full_description',
'department_name',
'unit_price_display',
'sale_price',
'sale_price_display',
'sale_ends',
'sale_ends_display',
'tpr_price',
'tpr_price_display',
'tpr_ends',
'tpr_ends_display',
'current_price',
'current_price_display',
'current_ends',
'current_ends_display',
'vendor_name',
'costs',
'image_url',
])
# but must supplement
cost = product.cost
data.update({
'upc': str(product.upc),
return {
'uuid': product.uuid,
'_str': six.text_type(product),
'upc': six.text_type(product.upc),
'scancode': product.scancode,
'item_id': product.item_id,
'item_type': product.item_type,
'description': product.description,
'status_code': product.status_code,
'default_unit_cost': cost.unit_cost if cost else None,
'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None,
})
return data
}
def make_autocomplete_query(self, term):
query = self.Session.query(model.Product)\
@ -110,104 +77,6 @@ class ProductView(APIMasterView):
def autocomplete_display(self, product):
return product.full_description
def quick_lookup(self):
"""
View for handling "quick lookup" user input, for index page.
"""
data = self.request.GET
entry = data['entry']
product = self.products_handler.locate_product_for_entry(self.Session(),
entry)
if not product:
return {'error': "Product not found"}
return {'ok': True,
'product': self.normalize(product)}
def label_profiles(self):
"""
Returns the set of label profiles available for use with
printing label for product.
"""
app = self.get_rattail_app()
label_handler = app.get_label_handler()
model = self.model
profiles = []
for profile in label_handler.get_label_profiles(self.Session()):
profiles.append({
'uuid': profile.uuid,
'description': profile.description,
})
return {'label_profiles': profiles}
def print_labels(self):
app = self.get_rattail_app()
label_handler = app.get_label_handler()
model = self.model
data = self.request.json_body
uuid = data.get('label_profile_uuid')
profile = self.Session.get(model.LabelProfile, uuid) if uuid else None
if not profile:
return {'error': "Label profile not found"}
uuid = data.get('product_uuid')
product = self.Session.get(model.Product, uuid) if uuid else None
if not product:
return {'error': "Product not found"}
try:
quantity = int(data.get('quantity'))
except:
return {'error': "Quantity must be integer"}
printer = label_handler.get_printer(profile)
if not printer:
return {'error': "Couldn't get printer from label profile"}
try:
printer.print_labels([({'product': product}, quantity)])
except Exception as error:
log.warning("error occurred while printing labels", exc_info=True)
return {'error': str(error)}
return {'ok': True}
@classmethod
def defaults(cls, config):
cls._defaults(config)
cls._product_defaults(config)
@classmethod
def _product_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
collection_url_prefix = cls.get_collection_url_prefix()
# quick lookup
quick_lookup = Service(name='{}.quick_lookup'.format(route_prefix),
path='{}/quick-lookup'.format(collection_url_prefix))
quick_lookup.add_view('GET', 'quick_lookup', klass=cls,
permission='{}.list'.format(permission_prefix))
config.add_cornice_service(quick_lookup)
# label profiles
label_profiles = Service(name=f'{route_prefix}.label_profiles',
path=f'{collection_url_prefix}/label-profiles')
label_profiles.add_view('GET', 'label_profiles', klass=cls,
permission=f'{permission_prefix}.print_labels')
config.add_cornice_service(label_profiles)
# print labels
print_labels = Service(name='{}.print_labels'.format(route_prefix),
path='{}/print-labels'.format(collection_url_prefix))
print_labels.add_view('POST', 'print_labels', klass=cls,
permission='{}.print_labels'.format(permission_prefix))
config.add_cornice_service(print_labels)
def defaults(config, **kwargs):
base = globals()

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Upgrade Views
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api import APIMasterView
@ -49,7 +53,7 @@ class UpgradeView(APIMasterView):
data['status_code'] = None
else:
data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
str(upgrade.status_code))
six.text_type(upgrade.status_code))
return data

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,8 @@
Tailbone Web API - User Views
"""
from __future__ import unicode_literals, absolute_import
from rattail.db import model
from tailbone.api import APIMasterView
@ -55,10 +57,6 @@ class UserView(APIMasterView):
query = query.outerjoin(model.Person)
return query
def update_object(self, user, data):
# TODO: should ensure prevent_password_change is respected
return super(UserView, self).update_object(user, data)
def defaults(config, **kwargs):
base = globals()

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Web API - Vendor Views
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api import APIMasterView
@ -40,7 +44,7 @@ class VendorView(APIMasterView):
def normalize(self, vendor):
return {
'uuid': vendor.uuid,
'_str': str(vendor),
'_str': six.text_type(vendor),
'id': vendor.id,
'name': vendor.name,
}

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,8 +24,12 @@
Tailbone Web API - Work Order Views
"""
from __future__ import unicode_literals, absolute_import
import datetime
import six
from rattail.db.model import WorkOrder
from cornice import Service
@ -40,19 +44,19 @@ class WorkOrderView(APIMasterView):
object_url_prefix = '/workorder'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super(WorkOrderView, self).__init__(*args, **kwargs)
app = self.get_rattail_app()
self.workorder_handler = app.get_workorder_handler()
def normalize(self, workorder):
data = super().normalize(workorder)
data = super(WorkOrderView, self).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 ''),
'date_submitted': six.text_type(workorder.date_submitted or ''),
'date_received': six.text_type(workorder.date_received or ''),
'date_released': six.text_type(workorder.date_released or ''),
'date_delivered': six.text_type(workorder.date_delivered or ''),
})
return data
@ -83,7 +87,7 @@ class WorkOrderView(APIMasterView):
if 'status_code' in data:
data['status_code'] = int(data['status_code'])
return super().update_object(workorder, data)
return super(WorkOrderView, self).update_object(workorder, data)
def status_codes(self):
"""

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,20 +24,25 @@
Application Entry Point
"""
import os
from __future__ import unicode_literals, absolute_import
import os
import warnings
import six
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker, scoped_session
from wuttjamaican.util import parse_list
from rattail.config import make_config
from rattail.config import make_config, parse_list
from rattail.exceptions import ConfigurationError
from rattail.db.types import GPCType
from pyramid.config import Configurator
from pyramid.authentication import SessionAuthenticationPolicy
from zope.sqlalchemy import register
import tailbone.db
from tailbone.auth import 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
@ -58,33 +63,16 @@ def make_rattail_config(settings):
"to the path of your config file. Lame, but necessary.")
rattail_config = make_config(path)
settings['rattail_config'] = rattail_config
# nb. this is for compaibility with wuttaweb
settings['wutta_config'] = rattail_config
# must import all sqlalchemy models before things get rolling,
# otherwise can have errors about continuum TransactionMeta class
# not yet mapped, when relevant pages are first requested...
# cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models
# hat tip to https://stackoverflow.com/a/59241485
if getattr(rattail_config, 'tempmon_engine', None):
from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
tempmon_session = TempmonSession()
tempmon_session.query(tempmon_model.Appliance).first()
tempmon_session.close()
rattail_config.configure_logging()
# configure database sessions
if hasattr(rattail_config, 'appdb_engine'):
tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
if hasattr(rattail_config, 'rattail_engine'):
tailbone.db.Session.configure(bind=rattail_config.rattail_engine)
if hasattr(rattail_config, 'trainwreck_engine'):
tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
if hasattr(rattail_config, 'tempmon_engine'):
tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine)
# maybe set "future" behavior for SQLAlchemy
if rattail_config.getbool('rattail.db', 'sqlalchemy_future_mode', usedb=False):
tailbone.db.Session.configure(future=True)
# create session wrappers for each "extra" Trainwreck engine
for key, engine in rattail_config.trainwreck_engines.items():
if key != 'default':
@ -135,21 +123,18 @@ def make_pyramid_config(settings, configure_csrf=True):
config.set_root_factory(Root)
else:
# declare this web app of the "classic" variety
settings.setdefault('tailbone.classic', 'true')
# we want the new themes feature!
establish_theme(settings)
settings.setdefault('fanstatic.versioning', 'true')
settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
config = Configurator(settings=settings, root_factory=Root)
# add rattail config directly to registry, for access throughout the app
# add rattail config directly to registry
config.registry['rattail_config'] = rattail_config
# configure user authorization / authentication
config.set_security_policy(TailboneSecurityPolicy())
config.set_authorization_policy(TailboneAuthorizationPolicy())
config.set_authentication_policy(SessionAuthenticationPolicy())
# maybe require CSRF token protection
if configure_csrf:
@ -160,20 +145,9 @@ def make_pyramid_config(settings, configure_csrf=True):
# Bring in some Pyramid goodies.
config.include('tailbone.beaker')
config.include('pyramid_deform')
config.include('pyramid_fanstatic')
config.include('pyramid_mako')
config.include('pyramid_tm')
# TODO: this may be a good idea some day, if wanting to leverage
# deform resources for component JS? cf. also base.mako template
# # override default script mapping for deform
# from deform import Field
# from deform.widget import ResourceRegistry, default_resources
# registry = ResourceRegistry(use_defaults=False)
# for key in default_resources:
# registry.set_js_resources(key, None, {'js': []})
# Field.set_default_resource_registry(registry)
# bring in the pyramid_retry logic, if available
# TODO: pretty soon we can require this package, hopefully..
try:
@ -185,7 +159,7 @@ def make_pyramid_config(settings, configure_csrf=True):
# fetch all tailbone providers
providers = get_all_providers(rattail_config)
for provider in providers.values():
for provider in six.itervalues(providers):
# configure DB sessions associated with transaction manager
provider.configure_db_sessions(rattail_config, config)
@ -196,22 +170,13 @@ def make_pyramid_config(settings, configure_csrf=True):
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')
# Add some permissions magic.
config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
# 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')
@ -226,7 +191,7 @@ def add_websocket(config, name, view, attr=None):
rattail_config = config.registry.settings['rattail_config']
rattail_app = rattail_config.get_app()
if isinstance(view, str):
if isinstance(view, six.string_types):
view_callable = rattail_app.load_object(view)
else:
view_callable = view
@ -274,36 +239,6 @@ def add_config_page(config, route_name, label, permission):
config.action(None, action)
def add_model_view(config, model_name, label, route_prefix, permission_prefix):
"""
Register a model view for the app.
"""
def action():
all_views = config.get_settings().get('tailbone_model_views', {})
model_views = all_views.setdefault(model_name, [])
model_views.append({
'label': label,
'route_prefix': route_prefix,
'permission_prefix': permission_prefix,
})
config.add_settings({'tailbone_model_views': all_views})
config.action(None, action)
def add_view_supplement(config, route_prefix, cls):
"""
Register a master view supplement for the app.
"""
def action():
supplements = config.get_settings().get('tailbone_view_supplements', {})
supplements.setdefault(route_prefix, []).append(cls)
config.add_settings({'tailbone_view_supplements': supplements})
config.action(None, action)
def establish_theme(settings):
rattail_config = settings['rattail_config']
@ -311,7 +246,7 @@ def establish_theme(settings):
settings['tailbone.theme'] = theme
directories = settings['mako.directories']
if isinstance(directories, str):
if isinstance(directories, six.string_types):
directories = parse_list(directories)
path = get_theme_template_path(rattail_config)
@ -332,8 +267,7 @@ def main(global_config, **settings):
"""
This function returns a Pyramid WSGI application.
"""
settings.setdefault('mako.directories', ['tailbone:templates',
'wuttaweb:templates'])
settings.setdefault('mako.directories', ['tailbone:templates'])
rattail_config = make_rattail_config(settings)
pyramid_config = make_pyramid_config(settings)
pyramid_config.include('tailbone')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,10 +24,14 @@
ASGI App Utilities
"""
from __future__ import unicode_literals, absolute_import
import os
import configparser
import logging
import six
from six.moves import configparser
from rattail.util import load_object
from asgiref.wsgi import WsgiToAsgi
@ -45,12 +49,6 @@ class TailboneWsgiToAsgi(WsgiToAsgi):
protocol = scope['type']
path = scope['path']
# strip off the root path, if non-empty. needed for serving
# under /poser or anything other than true site root
root_path = scope['root_path']
if root_path and path.startswith(root_path):
path = path[len(root_path):]
if protocol == 'websocket':
websockets = self.wsgi_application.registry.get(
'tailbone_websockets', {})
@ -87,7 +85,7 @@ def make_asgi_app(main_app=None):
# parse the settings needed for pyramid app
settings = dict(parser.items('app:main'))
if isinstance(main_app, str):
if isinstance(main_app, six.string_types):
make_wsgi_app = load_object(main_app)
elif callable(main_app):
make_wsgi_app = main_app

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2021 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,31 +24,32 @@
Authentication & Authorization
"""
from __future__ import unicode_literals, absolute_import
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
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
returns a ``headers`` dict which you should pass to the redirect.
"""
config = request.rattail_config
app = config.get_app()
user.record_event(app.enum.USER_EVENT_LOGIN)
user.record_event(enum.USER_EVENT_LOGIN)
headers = remember(request, user.uuid)
if timeout is UNSPECIFIED:
timeout = session_timeout_for_user(config, user)
if timeout is NOTSET:
timeout = session_timeout_for_user(user)
log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
set_session_timeout(request, timeout)
return headers
@ -59,28 +60,24 @@ def logout_user(request):
Perform the logout action for the given request. Note that this returns a
``headers`` dict which you should pass to the redirect.
"""
app = request.rattail_config.get_app()
user = request.user
if user:
user.record_event(app.enum.USER_EVENT_LOGOUT)
user.record_event(enum.USER_EVENT_LOGOUT)
request.session.delete()
request.session.invalidate()
headers = forget(request)
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
"""
app = config.get_app()
auth = app.get_auth_handler()
from rattail.db.auth import authenticated_role
authenticated = auth.get_role_authenticated(Session())
roles = user.roles + [authenticated]
roles = user.roles + [authenticated_role(Session())]
timeouts = [role.session_timeout for role in roles
if role.session_timeout is not None]
if timeouts and 0 not in timeouts:
return max(timeouts)
@ -92,42 +89,58 @@ def set_session_timeout(request, timeout):
request.session['_timeout'] = timeout or None
class TailboneSecurityPolicy(WuttaSecurityPolicy):
@implementer(IAuthorizationPolicy)
class TailboneAuthorizationPolicy(object):
def __init__(self, db_session=None, api_mode=False, **kwargs):
kwargs['db_session'] = db_session or Session()
super().__init__(**kwargs)
self.api_mode = api_mode
def load_identity(self, request):
config = request.registry.settings.get('rattail_config')
def permits(self, context, principals, permission):
config = context.request.rattail_config
model = config.get_model()
app = config.get_app()
user = None
auth = app.get_auth_handler()
if self.api_mode:
for userid in principals:
if userid not in (Everyone, Authenticated):
if context.request.user and context.request.user.uuid == userid:
return context.request.has_perm(permission)
else:
# this is pretty rare, but can happen in dev after
# re-creating the database, which means new user uuids.
# TODO: the odds of this query returning a user in that
# case, are probably nil, and we should just skip this bit?
user = Session.query(model.User).get(userid)
if user:
if auth.has_permission(Session(), user, permission):
return True
if Everyone in principals:
return auth.has_permission(Session(), None, permission)
return False
# determine/load user from header token if present
credentials = request.headers.get('Authorization')
if credentials:
match = re.match(r'^Bearer (\S+)$', credentials)
if match:
token = match.group(1)
auth = app.get_auth_handler()
user = auth.authenticate_user_token(self.db_session, token)
def principals_allowed_by_permission(self, context, permission):
raise NotImplementedError
if not user:
# fetch user uuid from current session
uuid = self.session_helper.authenticated_userid(request)
if not uuid:
return
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 object from db
model = app.model
user = self.db_session.get(model.User, uuid)
if not user:
return
# this user is responsible for data changes in current request
self.db_session.set_continuum_user(user)
return user
def add_permission(config, groupkey, key, label=None):
"""
Add a permission to the app configuration.
"""
def action():
perms = config.get_settings().get('tailbone_permissions', {})
group = perms.setdefault(groupkey, {'key': groupkey})
group.setdefault('label', prettify(groupkey))
perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
perm['label'] = label or prettify(key)
config.add_settings({'tailbone_permissions': perms})
config.action(None, action)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -27,11 +27,11 @@ Note that most of the code for this module was copied from the beaker and
pyramid_beaker projects.
"""
from __future__ import unicode_literals, absolute_import
import time
from pkg_resources import parse_version
from rattail.util import get_pkg_version
import beaker
from beaker.session import Session
from beaker.util import coerce_session_params
@ -49,7 +49,7 @@ class TailboneSession(Session):
"Loads the data from this session from persistent storage"
# are we using older version of beaker?
old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12')
old_beaker = parse_version(beaker.__version__) < parse_version('1.12')
self.namespace = self.namespace_class(self.id,
data_dir=self.data_dir,

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

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,16 +24,15 @@
Rattail config extension for Tailbone
"""
import warnings
from wuttjamaican.conf import WuttaConfigExtension
from __future__ import unicode_literals, absolute_import
from rattail.config import ConfigExtension as BaseExtension
from rattail.db.config import configure_session
from tailbone.db import Session
class ConfigExtension(WuttaConfigExtension):
class ConfigExtension(BaseExtension):
"""
Rattail config extension for Tailbone. Does the following:
@ -50,12 +49,9 @@ class ConfigExtension(WuttaConfigExtension):
configure_session(config, Session)
# provide default theme selection
config.setdefault('tailbone', 'themes.keys', 'default, butterball')
config.setdefault('tailbone', 'themes', 'default, falafel')
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')

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2021 Lance Edgar
#
# This file is part of Rattail.
#
@ -21,13 +21,16 @@
#
################################################################################
"""
Database sessions etc.
Database Stuff
"""
from __future__ import unicode_literals, absolute_import
import sqlalchemy as sa
from zope.sqlalchemy import datamanager
import sqlalchemy_continuum as continuum
from sqlalchemy.orm import sessionmaker, scoped_session
from pkg_resources import get_distribution, parse_version
from rattail.db import SessionBase
from rattail.db.continuum import versioning_manager
@ -42,28 +45,23 @@ TrainwreckSession = scoped_session(sessionmaker())
# empty dict for now, this must populated on app startup (if needed)
ExtraTrainwreckSessions = {}
# some of the logic below may need to vary somewhat, based on which version of
# zope.sqlalchemy we have installed
zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version
zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version)
class TailboneSessionDataManager(datamanager.SessionDataManager):
"""
Integrate a top level sqlalchemy session transaction into a zope
transaction
"""Integrate a top level sqlalchemy session transaction into a zope transaction
One phase variant.
.. note::
This class appears to be necessary in order for the
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It subclasses
``zope.sqlalchemy.datamanager.SessionDataManager`` but injects
some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and
is sort of monkey-patched into the mix.
This class appears to be necessary in order for the Continuum
integration to work alongside the Zope transaction integration.
"""
def tpc_vote(self, trans):
""" """
# for a one phase data manager commit last in tpc_vote
if self.tx is not None: # there may have been no work to do
@ -75,117 +73,82 @@ class TailboneSessionDataManager(datamanager.SessionDataManager):
self._finish('committed')
def join_transaction(
session,
initial_state=datamanager.STATUS_ACTIVE,
transaction_manager=datamanager.zope_transaction.manager,
keep_session=False,
):
"""
Join a session to a transaction using the appropriate datamanager.
def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
"""Join a session to a transaction using the appropriate datamanager.
It is safe to call this multiple times, if the session is already
joined then it just returns.
It is safe to call this multiple times, if the session is already joined
then it just returns.
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or
STATUS_READONLY
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY
If using the default initial status of STATUS_ACTIVE, you must
ensure that mark_changed(session) is called when data is written
to the database.
If using the default initial status of STATUS_ACTIVE, you must ensure that
mark_changed(session) is called when data is written to the database.
The ZopeTransactionExtesion SessionExtension can be used to ensure
that this is called automatically after session write operations.
The ZopeTransactionExtesion SessionExtension can be used to ensure that this is
called automatically after session write operations.
.. note::
This function appears to be necessary in order for the
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It overrides ``zope.sqlalchemy.datamanager.join_transaction()``
to ensure the custom :class:`TailboneSessionDataManager` is
used, and is sort of monkey-patched into the mix.
This function is copied from upstream, and tweaked so that our custom
:class:`TailboneSessionDataManager` will be used.
"""
# the upstream internals of this function has changed a little over time.
# unfortunately for us, that means we must include each variant here.
if datamanager._SESSION_STATE.get(session, None) is None:
if session.twophase:
DataManager = datamanager.TwoPhaseSessionDataManager
else:
DataManager = TailboneSessionDataManager
DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+
if datamanager._SESSION_STATE.get(session, None) is None:
if session.twophase:
DataManager = datamanager.TwoPhaseSessionDataManager
else:
DataManager = TailboneSessionDataManager
DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
else: # pre-1.1
if datamanager._SESSION_STATE.get(id(session), None) is None:
if session.twophase:
DataManager = datamanager.TwoPhaseSessionDataManager
else:
DataManager = TailboneSessionDataManager
DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
class ZopeTransactionEvents(datamanager.ZopeTransactionEvents):
"""
Record that a flush has occurred on a session's connection. This
allows the DataManager to rollback rather than commit on read only
transactions.
class ZopeTransactionExtension(datamanager.ZopeTransactionExtension):
"""Record that a flush has occurred on a session's connection. This allows
the DataManager to rollback rather than commit on read only transactions.
.. note::
This class appears to be necessary in order for the
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It subclasses
``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but
overrides various methods to ensure the custom
:func:`join_transaction()` is called, and is sort of
monkey-patched into the mix.
This class is copied from upstream, and tweaked so that our custom
:func:`join_transaction()` will be used.
"""
def after_begin(self, session, transaction, connection):
""" """
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session)
def after_attach(self, session, instance):
""" """
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
def join_transaction(self, session):
""" """
join_transaction(session, self.initial_state,
self.transaction_manager, self.keep_session)
join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session)
def register(
session,
initial_state=datamanager.STATUS_ACTIVE,
transaction_manager=datamanager.zope_transaction.manager,
keep_session=False,
):
"""
Register ZopeTransaction listener events on the given Session or
Session factory/class.
def register(session, initial_state=datamanager.STATUS_ACTIVE,
transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
"""Register ZopeTransaction listener events on the
given Session or Session factory/class.
This function requires at least SQLAlchemy 0.7 and makes use of
the newer sqlalchemy.event package in order to register event
listeners on the given Session.
This function requires at least SQLAlchemy 0.7 and makes use
of the newer sqlalchemy.event package in order to register event listeners
on the given Session.
The session argument here may be a Session class or subclass, a
sessionmaker or scoped_session instance, or a specific Session
instance. Event listening will be specific to the scope of the
type of argument passed, including specificity to its subclass as
well as its identity.
sessionmaker or scoped_session instance, or a specific Session instance.
Event listening will be specific to the scope of the type of argument
passed, including specificity to its subclass as well as its identity.
.. note::
This function appears to be necessary in order for the
SQLAlchemy-Continuum integration to work alongside the Zope
transaction integration.
It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to
ensure the custom :class:`ZopeTransactionEvents` is used.
This function is copied from upstream, and tweaked so that our custom
:class:`ZopeTransactionExtension` will be used.
"""
from sqlalchemy import event
ext = ZopeTransactionEvents(
initial_state=initial_state,
ext = ZopeTransactionExtension(
initial_state=initial_state,
transaction_manager=transaction_manager,
keep_session=keep_session,
)
@ -197,9 +160,6 @@ def register(
event.listen(session, "after_bulk_delete", ext.after_bulk_delete)
event.listen(session, "before_commit", ext.before_commit)
if datamanager.SA_GE_14:
event.listen(session, "do_orm_execute", ext.do_orm_execute)
register(Session)
register(TempmonSession)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2019 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,8 +24,7 @@
Tools for displaying data diffs
"""
import sqlalchemy as sa
import sqlalchemy_continuum as continuum
from __future__ import unicode_literals, absolute_import
from pyramid.renderers import render
from webhelpers2.html import HTML
@ -34,41 +33,37 @@ from webhelpers2.html import HTML
class Diff(object):
"""
Core diff class. In sore need of documentation.
You must provide the old and new data sets, and the set of
relevant fields as well, if they cannot be easily introspected.
:param old_data: Dict of "old" data values.
:param new_data: Dict of "old" data values.
:param fields: Sequence of relevant field names. Note that
both data dicts are expected to have keys which match these
field names. If you do not specify the fields then they
will (hopefully) be introspected from the old or new data
sets; however this will not work if they are both empty.
:param monospace: If true, this flag will cause the value
columns to be rendered in monospace font. This is assumed
to be helpful when comparing "raw" data values which are
shown as e.g. ``repr(val)``.
:param enums: Optional dict of enums for use when displaying field
values. If specified, keys should be field names and values
should be enum dicts.
"""
def __init__(self, old_data, new_data, columns=None, fields=None, enums=None,
render_field=None, render_value=None, nature='dirty',
def __init__(self, old_data, new_data, columns=None, fields=None,
render_field=None, render_value=None,
monospace=False, extra_row_attrs=None):
"""
Constructor. You must provide the old and new data sets, and
the set of relevant fields as well, if they cannot be easily
introspected.
:param old_data: Dict of "old" data values.
:param new_data: Dict of "old" data values.
:param fields: Sequence of relevant field names. Note that
both data dicts are expected to have keys which match these
field names. If you do not specify the fields then they
will (hopefully) be introspected from the old or new data
sets; however this will not work if they are both empty.
:param monospace: If true, this flag will cause the value
columns to be rendered in monospace font. This is assumed
to be helpful when comparing "raw" data values which are
shown as e.g. ``repr(val)``.
"""
self.old_data = old_data
self.new_data = new_data
self.columns = columns or ["field name", "old value", "new value"]
self.fields = fields or self.make_fields()
self.enums = enums or {}
self._render_field = render_field or self.render_field_default
self.render_value = render_value or self.render_value_default
self.nature = nature
self.monospace = monospace
self.extra_row_attrs = extra_row_attrs
@ -95,7 +90,7 @@ class Diff(object):
for the given field. May be an empty string, or a snippet of HTML
attribute syntax, e.g.:
.. code-block:: none
.. code-highlight:: none
class="diff" foo="bar"
@ -131,161 +126,3 @@ class Diff(object):
def render_new_value(self, field):
value = self.new_value(field)
return self.render_value(field, value)
class VersionDiff(Diff):
"""
Special diff class, for use with version history views. Note that
while based on :class:`Diff`, this class uses a different
signature for the constructor.
:param version: Reference to a Continuum version record (object).
:param \*args: Typical usage will not require positional args
beyond the ``version`` param, in which case ``old_data`` and
``new_data`` params will be auto-determined based on the
``version``. But if you specify positional args then nothing
automatic is done, they are passed as-is to the parent
:class:`Diff` constructor.
:param \*\*kwargs: Remaining kwargs are passed as-is to the
:class:`Diff` constructor.
"""
def __init__(self, version, *args, **kwargs):
self.version = version
self.mapper = sa.inspect(continuum.parent_class(type(self.version)))
self.version_mapper = sa.inspect(type(self.version))
self.title = kwargs.pop('title', None)
if 'nature' not in kwargs:
if version.previous and version.operation_type == continuum.Operation.DELETE:
kwargs['nature'] = 'deleted'
elif version.previous:
kwargs['nature'] = 'dirty'
else:
kwargs['nature'] = 'new'
if 'fields' not in kwargs:
kwargs['fields'] = self.get_default_fields()
if not args:
old_data = {}
new_data = {}
for field in kwargs['fields']:
if version.previous:
old_data[field] = getattr(version.previous, field)
new_data[field] = getattr(version, field)
args = (old_data, new_data)
super().__init__(*args, **kwargs)
def get_default_fields(self):
fields = sorted(self.version_mapper.columns.keys())
unwanted = [
'transaction_id',
'end_transaction_id',
'operation_type',
]
return [field for field in fields
if field not in unwanted]
def render_version_value(self, field, value, version):
"""
Render the cell value text for the given version/field info.
Note that this method is used to render both sides of the diff
(before and after values).
:param field: Name of the field, as string.
:param value: Raw value for the field, as obtained from ``version``.
:param version: Reference to the Continuum version object.
:returns: Rendered text as string, or ``None``.
"""
text = HTML.tag('span', c=[repr(value)],
style='font-family: monospace;')
# assume the enum display is all we need, if enum exists for the field
if field in self.enums:
# but skip the enum display if None
display = self.enums[field].get(value)
if display is None and value is None:
return text
# otherwise show enum display to the right of raw value
display = self.enums[field].get(value, str(value))
return HTML.tag('span', c=[
text,
HTML.tag('span', c=[display],
style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
])
# next we look for a relationship and may render the foreign object
for prop in self.mapper.relationships:
if prop.uselist:
continue
for col in prop.local_columns:
if col.name != field:
continue
if not hasattr(version, prop.key):
continue
if col in self.mapper.primary_key:
continue
ref = getattr(version, prop.key)
if ref:
ref = getattr(ref, 'version_parent', None)
if ref:
return HTML.tag('span', c=[
text,
HTML.tag('span', c=[str(ref)],
style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
])
return text
def render_old_value(self, field):
if self.nature == 'new':
return ''
value = self.old_value(field)
return self.render_version_value(field, value, self.version.previous)
def render_new_value(self, field):
if self.nature == 'deleted':
return ''
value = self.new_value(field)
return self.render_version_value(field, value, self.version)
def as_struct(self):
values = {}
for field in self.fields:
values[field] = {'before': self.render_old_value(field),
'after': self.render_new_value(field)}
operation = None
if self.version.operation_type == continuum.Operation.INSERT:
operation = 'INSERT'
elif self.version.operation_type == continuum.Operation.UPDATE:
operation = 'UPDATE'
elif self.version.operation_type == continuum.Operation.DELETE:
operation = 'DELETE'
else:
operation = self.version.operation_type
return {
'key': id(self.version),
'model_title': self.title,
'operation': operation,
'diff_class': self.nature,
'fields': self.fields,
'values': values,
}

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2020 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,10 @@
Tailbone Exceptions
"""
from __future__ import unicode_literals, absolute_import
import six
from rattail.exceptions import RattailError
@ -33,6 +37,7 @@ class TailboneError(RattailError):
"""
@six.python_2_unicode_compatible
class TailboneJSONFieldError(TailboneError):
"""
Error raised when JSON serialization of a form field results in an error.

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,8 @@
Common Forms
"""
from __future__ import unicode_literals, absolute_import
from rattail.db import model
import colander
@ -33,7 +35,7 @@ import colander
def validate_user(node, kw):
session = kw['session']
def validate(node, value):
user = session.get(model.User, value)
user = session.query(model.User).get(value)
if not user:
raise colander.Invalid(node, "User not found")
return user.uuid

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,18 +24,19 @@
Forms Core
"""
import hashlib
from __future__ import unicode_literals, absolute_import
import json
import logging
import warnings
from collections import OrderedDict
import six
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY
from wuttjamaican.util import UNSPECIFIED
from rattail.util import pretty_boolean
from rattail.time import localtime
from rattail.util import prettify, pretty_boolean, pretty_quantity
from rattail.core import UNSPECIFIED
from rattail.db.util import get_fieldnames
import colander
@ -47,14 +48,9 @@ from pyramid_deform import SessionFileUploadTempStore
from pyramid.renderers import render
from webhelpers2.html import tags, HTML
from wuttaweb.util import FieldList, get_form_data, make_json_safe
from tailbone.db import Session
from tailbone.util import raw_datetime, render_markdown
from tailbone.forms import types
from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget,
JQueryDateWidget, JQueryTimeWidget,
FileUploadWidget, MultiFileUploadWidget)
from tailbone.util import raw_datetime, get_form_data
from . import types
from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget
from tailbone.exceptions import TailboneJSONFieldError
@ -226,7 +222,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
if excludes:
overrides['excludes'] = excludes
return super().get_schema_from_relationship(prop, overrides)
return super(CustomSchemaNode, self).get_schema_from_relationship(prop, overrides)
def dictify(self, obj):
""" Return a dictified version of `obj` using schema information.
@ -235,7 +231,7 @@ class CustomSchemaNode(SQLAlchemySchemaNode):
This method was copied from upstream and modified to add automatic
handling of "association proxy" fields.
"""
dict_ = super().dictify(obj)
dict_ = super(CustomSchemaNode, self).dictify(obj)
for node in self:
name = node.name
@ -328,7 +324,7 @@ class Form(object):
"""
Base class for all forms.
"""
save_label = "Submit"
save_label = "Save"
update_label = "Save"
show_cancel = True
auto_disable = True
@ -339,12 +335,8 @@ class Form(object):
model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={},
assume_local_times=False, renderers=None, renderer_kwargs={},
hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
action_url=None, cancel_url=None,
vue_tagname=None,
vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={},
# TODO: ugh this is getting out hand!
can_edit_help=False, edit_help_url=None, route_prefix=None,
**kwargs
action_url=None, cancel_url=None, use_buefy=None, component='tailbone-form',
vuejs_field_converters={},
):
self.fields = None
if fields is not None:
@ -352,7 +344,6 @@ class Form(object):
self.schema = schema
if self.fields is None and self.schema:
self.set_fields([f.name for f in self.schema])
self.grouping = None
self.request = request
self.readonly = readonly
self.readonly_fields = set(readonly_fields or [])
@ -378,82 +369,20 @@ class Form(object):
self.validators = validators or {}
self.required = required or {}
self.helptext = helptext or {}
self.dynamic_helptext = {}
self.focus_spec = focus_spec
self.action_url = action_url
self.cancel_url = cancel_url
# vue_tagname
self.vue_tagname = vue_tagname
if not self.vue_tagname and kwargs.get('component'):
warnings.warn("component kwarg is deprecated for Form(); "
"please use vue_tagname param instead",
DeprecationWarning, stacklevel=2)
self.vue_tagname = kwargs['component']
if not self.vue_tagname:
self.vue_tagname = 'tailbone-form'
self.vuejs_component_kwargs = vuejs_component_kwargs or {}
self.use_buefy = use_buefy
self.component = component
self.vuejs_field_converters = vuejs_field_converters or {}
self.json_data = json_data or {}
self.included_templates = included_templates or {}
self.can_edit_help = can_edit_help
self.edit_help_url = edit_help_url
self.route_prefix = route_prefix
self.button_icon_submit = kwargs.get('button_icon_submit', 'save')
def __iter__(self):
return iter(self.fields)
@property
def vue_component(self):
"""
String name for the Vue component, e.g. ``'TailboneGrid'``.
This is a generated value based on :attr:`vue_tagname`.
"""
words = self.vue_tagname.split('-')
return ''.join([word.capitalize() for word in words])
@property
def component(self):
"""
DEPRECATED - use :attr:`vue_tagname` instead.
"""
warnings.warn("Form.component is deprecated; "
"please use vue_tagname instead",
DeprecationWarning, stacklevel=2)
return self.vue_tagname
@property
def component_studly(self):
"""
DEPRECATED - use :attr:`vue_component` instead.
"""
warnings.warn("Form.component_studly is deprecated; "
"please use vue_component instead",
DeprecationWarning, stacklevel=2)
return self.vue_component
def get_button_label_submit(self):
""" """
if hasattr(self, '_button_label_submit'):
return self._button_label_submit
label = getattr(self, 'submit_label', None)
if label:
return label
return self.save_label
def set_button_label_submit(self, value):
""" """
self._button_label_submit = value
# wutta compat
button_label_submit = property(get_button_label_submit,
set_button_label_submit)
words = self.component.split('-')
return ''.join([word.capitalize() for word in words])
def __contains__(self, item):
return item in self.fields
@ -471,9 +400,6 @@ class Form(object):
return get_fieldnames(self.request.rattail_config, self.model_class,
columns=True, proxies=True, relations=True)
def set_grouping(self, items):
self.grouping = OrderedDict(items)
def make_renderers(self):
"""
Return a default set of field renderers, based on :attr:`model_class`.
@ -630,9 +556,7 @@ class Form(object):
self.schema[key].title = label
def get_label(self, key):
config = self.request.rattail_config
app = config.get_app()
return self.labels.get(key, app.make_title(key))
return self.labels.get(key, prettify(key))
def set_readonly(self, key, readonly=True):
if readonly:
@ -649,23 +573,9 @@ class Form(object):
node = colander.SchemaNode(nodeinfo, **kwargs)
self.nodes[key] = node
# must explicitly replace node, if we already have a schema
if self.schema:
self.schema[key] = node
def set_type(self, key, type_, **kwargs):
if type_ == 'datetime':
self.set_renderer(key, self.render_datetime)
elif type_ == 'datetime_falafel':
self.set_renderer(key, self.render_datetime)
self.set_node(key, types.FalafelDateTime(request=self.request))
if kwargs.get('helptext'):
app = self.request.rattail_config.get_app()
timezone = app.get_timezone()
self.set_helptext(key, f"NOTE: all times are local to {timezone}")
elif type_ == 'datetime_local':
self.set_renderer(key, self.render_datetime_local)
elif type_ == 'date_plain':
@ -674,14 +584,9 @@ class Form(object):
# TODO: is this safe / a good idea?
# self.set_node(key, colander.Date())
self.set_widget(key, JQueryDateWidget())
elif type_ == 'time_jquery':
self.set_node(key, types.JQueryTime())
self.set_widget(key, JQueryTimeWidget())
elif type_ == 'time_falafel':
self.set_node(key, types.FalafelTime(request=self.request))
elif type_ == 'duration':
self.set_renderer(key, self.render_duration)
elif type_ == 'boolean':
@ -708,40 +613,17 @@ class Form(object):
self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
elif type_ == 'file':
tmpstore = SessionFileUploadTempStore(self.request)
kw = {'widget': FileUploadWidget(tmpstore, request=self.request),
kw = {'widget': dfwidget.FileUploadWidget(tmpstore),
'title': self.get_label(key)}
if 'required' in kwargs and not kwargs['required']:
kw['missing'] = colander.null
self.set_node(key, colander.SchemaNode(deform.FileData(), **kw))
elif type_ == 'multi_file':
tmpstore = SessionFileUploadTempStore(self.request)
file_node = colander.SchemaNode(deform.FileData(),
name='upload')
kw = {'name': key,
'title': self.get_label(key),
'widget': MultiFileUploadWidget(tmpstore)}
# if 'required' in kwargs and not kwargs['required']:
# kw['missing'] = colander.null
if kwargs.get('validate_unique'):
kw['validator'] = self.validate_multiple_files_unique
files_node = colander.SequenceSchema(file_node, **kw)
self.set_node(key, files_node)
# must explicitly replace node, if we already have a schema
if self.schema:
self.schema[key] = self.nodes[key]
else:
raise ValueError("unknown type for '{}' field: {}".format(key, type_))
def validate_multiple_files_unique(self, node, value):
# get SHA256 hash for each file; error if duplicates encountered
hashes = {}
for fileinfo in value:
fp = fileinfo['fp']
fp.seek(0)
filehash = hashlib.sha256(fp.read()).hexdigest()
if filehash in hashes:
node.raise_invalid(f"Duplicate file detected: {fileinfo['filename']}")
hashes[filehash] = fileinfo
def set_enum(self, key, enum, empty=None):
if enum:
self.enums[key] = enum
@ -805,8 +687,9 @@ class Form(object):
case the validator pertains to the form at large instead of
one of the fields.
:param validator: Callable which accepts ``(node, value)``
args.
TODO: what should the validator look like?
:param validator: Callable validator for the node.
"""
self.validators[key] = validator
@ -828,16 +711,11 @@ class Form(object):
"""
self.defaults[key] = value
def set_helptext(self, key, value, dynamic=False):
def set_helptext(self, key, value):
"""
Set the help text for a given field.
"""
# nb. must avoid newlines, they cause some weird "blank page" error?!
self.helptext[key] = value.replace('\n', ' ')
if value and dynamic:
self.dynamic_helptext[key] = True
else:
self.dynamic_helptext.pop(key, None)
self.helptext[key] = value
def has_helptext(self, key):
"""
@ -857,15 +735,15 @@ class Form(object):
def set_vuejs_field_converter(self, field, converter):
self.vuejs_field_converters[field] = converter
def render(self, **kwargs):
warnings.warn("Form.render() is deprecated (for now?); "
"please use Form.render_deform() instead",
DeprecationWarning, stacklevel=2)
return self.render_deform(**kwargs)
def get_deform(self):
""" """
return self.make_deform_form()
def render(self, template=None, **kwargs):
if not template:
if self.readonly and not self.use_buefy:
template = '/forms/form_readonly.mako'
else:
template = '/forms/form.mako'
context = kwargs
context['form'] = self
return render(template, context)
def make_deform_form(self):
if not hasattr(self, 'deform_form'):
@ -905,35 +783,31 @@ class Form(object):
return self.deform_form
def render_vue_template(self, template='/forms/deform.mako', **context):
""" """
output = self.render_deform(template=template, **context)
return HTML.literal(output)
def render_deform(self, dform=None, template=None, **kwargs):
if not template:
template = '/forms/deform.mako'
if self.use_buefy:
template = '/forms/deform_buefy.mako'
else:
template = '/forms/deform.mako'
if dform is None:
dform = self.make_deform_form()
# TODO: would perhaps be nice to leverage deform's default rendering
# someday..? i.e. using Chameleon *.pt templates
# return dform.render()
# return form.render()
context = kwargs
context['form'] = self
context['dform'] = dform
context.setdefault('can_edit_help', self.can_edit_help)
if context['can_edit_help']:
context.setdefault('edit_help_url', self.edit_help_url)
context['field_labels'] = self.get_field_labels()
context['field_markdowns'] = self.get_field_markdowns()
context.setdefault('form_kwargs', {})
# TODO: deprecate / remove the latter option here
if self.auto_disable_save or self.auto_disable:
context['form_kwargs'].setdefault('ref', self.vue_component)
context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component)
if self.use_buefy:
context['form_kwargs'].setdefault('ref', self.component_studly)
context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly)
else:
context['form_kwargs']['class_'] = 'autodisable'
if self.focus_spec:
context['form_kwargs']['data-focus'] = self.focus_spec
context['request'] = self.request
@ -941,36 +815,6 @@ class Form(object):
context['render_field_readonly'] = self.render_field_readonly
return render(template, context)
def get_field_labels(self):
return dict([(field, self.get_label(field))
for field in self])
def get_field_markdowns(self, session=None):
app = self.request.rattail_config.get_app()
model = app.model
session = session or Session()
if not hasattr(self, 'field_markdowns'):
infos = session.query(model.TailboneFieldInfo)\
.filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\
.all()
self.field_markdowns = dict([(info.field_name, info.markdown_text)
for info in infos])
return self.field_markdowns
def get_vue_field_value(self, key):
""" """
if key not in self.fields:
return
dform = self.get_deform()
if key not in dform:
return
field = dform[key]
return make_json_safe(field.cstruct)
def get_vuejs_model_value(self, field):
"""
This method must return "raw" JS which will be assigned as the initial
@ -982,38 +826,25 @@ class Form(object):
value = convert(field.cstruct)
return json.dumps(value)
if isinstance(field.schema.typ, deform.FileData):
# TODO: we used to always/only return 'null' here but hopefully
# this also works, to show existing filename when present
if field.cstruct and field.cstruct['filename']:
return json.dumps({'name': field.cstruct['filename']})
return 'null'
if isinstance(field.schema.typ, colander.Set):
if field.cstruct is colander.null:
return '[]'
if field.cstruct is colander.null:
return 'null'
try:
return self.jsonify_value(field.cstruct)
return json.dumps(field.cstruct)
except Exception as error:
raise TailboneJSONFieldError(field.name, error)
def jsonify_value(self, value):
"""
Take a Python value and convert to JSON
"""
if value is colander.null:
return 'null'
if isinstance(value, dfwidget.filedict):
# TODO: we used to always/only return 'null' here but hopefully
# this also works, to show existing filename when present
if value and value['filename']:
return json.dumps({'name': value['filename']})
return 'null'
elif isinstance(value, list) and all([isinstance(f, dfwidget.filedict)
for f in value]):
return json.dumps([{'name': f['filename']}
for f in value])
app = self.request.rattail_config.get_app()
value = app.json_friendly(value)
return json.dumps(value)
def get_error_messages(self, field):
if field.error:
return field.error.messages()
@ -1034,208 +865,68 @@ class Form(object):
return False
return True
def set_vuejs_component_kwargs(self, **kwargs):
self.vuejs_component_kwargs.update(kwargs)
def render_vue_tag(self, **kwargs):
""" """
return self.render_vuejs_component(**kwargs)
def render_vuejs_component(self, **kwargs):
def render_buefy_field(self, fieldname, bfield_attrs={}):
"""
Render the Vue.js component HTML for the form.
Most typically this is something like:
.. code-block:: html
<tailbone-form :configure-fields-help="configureFieldsHelp">
</tailbone-form>
"""
kw = dict(self.vuejs_component_kwargs)
kw.update(kwargs)
if self.can_edit_help:
kw.setdefault(':configure-fields-help', 'configureFieldsHelp')
return HTML.tag(self.vue_tagname, **kw)
def set_json_data(self, key, value):
"""
Establish a data value for use in client-side JS. This value
will be JSON-encoded and made available to the
`<tailbone-form>` component within the client page.
"""
self.json_data[key] = value
def include_template(self, template, context):
"""
Declare a JS template as required by the current form. This
template will then be included in the final page, so all
widgets behave correctly.
"""
self.included_templates[template] = context
def render_included_templates(self):
templates = []
for template, context in self.included_templates.items():
context = dict(context)
context['form'] = self
templates.append(HTML.literal(render(template, context)))
return HTML.literal('\n').join(templates)
def render_vue_field(self, fieldname, **kwargs):
""" """
return self.render_field_complete(fieldname, **kwargs)
def render_field_complete(self, fieldname, bfield_attrs={},
session=None):
"""
Render the given field completely, i.e. with ``<b-field>``
wrapper. Note that this is meant to render *editable* fields,
i.e. showing a widget, unless the field input is hidden. In
other words it's not for "readonly" fields.
Render the given field in a Buefy-compatible way. Note that
this is meant to render *editable* fields, i.e. showing a
widget, unless the field input is hidden. In other words it's
not for "readonly" fields.
"""
dform = self.make_deform_form()
field = dform[fieldname] if fieldname in dform else None
include = bool(field)
if self.readonly or (not field and fieldname in self.readonly_fields):
include = True
if not include:
return
field = dform[fieldname]
if self.field_visible(fieldname):
label = self.get_label(fieldname)
markdowns = self.get_field_markdowns(session=session)
# these attrs will be for the <b-field> (*not* the widget)
attrs = {
':horizontal': 'true',
'label': self.get_label(fieldname),
}
# add some magic for file input fields
if field and isinstance(field.schema.typ, deform.FileData):
if isinstance(field.schema.typ, deform.FileData):
attrs['class_'] = 'file'
# next we will build array of messages to display..some
# fields always show a "helptext" msg, and some may have
# validation errors..
field_type = None
messages = []
# show helptext if present
if self.has_helptext(fieldname):
attrs['message'] = self.render_helptext(fieldname)
# show errors if present
error_messages = self.get_error_messages(field) if field else None
error_messages = self.get_error_messages(field)
if error_messages:
field_type = 'is-danger'
messages.extend(error_messages)
# show helptext if present
# TODO: older logic did this only if field was *not*
# readonly, perhaps should add that back..
if self.has_helptext(fieldname):
messages.append(self.render_helptext(fieldname))
# TODO: this surely can't be what we ought to do
# here..? seems like we must pass JS but not JSON,
# sort of, so we custom-write the JS code to ensure
# single instead of double quotes delimit strings
# within the code.
message = '[{}]'.format(', '.join([
"'{}'".format(msg.replace("'", r"\'"))
for msg in error_messages]))
# ..okay now we can declare the field messages and type
if field_type:
attrs['type'] = field_type
if messages:
if len(messages) == 1:
msg = messages[0]
if msg.startswith('`') and msg.endswith('`'):
attrs[':message'] = msg
else:
attrs['message'] = msg
else:
# nb. must pass an array as JSON string
attrs[':message'] = '[{}]'.format(', '.join([
"'{}'".format(msg.replace("'", r"\'"))
for msg in messages]))
attrs.update({
'type': 'is-danger',
':message': message,
})
# merge anything caller provided
attrs.update(bfield_attrs)
# render the field widget or whatever
if self.readonly or fieldname in self.readonly_fields:
html = self.render_field_value(fieldname) or HTML.tag('span')
if type(html) is str:
html = HTML.tag('span', c=[html])
elif field:
html = field.serialize(**self.get_renderer_kwargs(fieldname))
html = HTML.literal(html)
# may need a complex label
label_contents = [label]
# add 'help' icon/tooltip if defined
if markdowns.get(fieldname):
icon = HTML.tag('b-icon', size='is-small', pack='fas',
icon='question-circle')
tooltip = render_markdown(markdowns[fieldname])
# nb. must apply hack to get <template #content> as final result
tooltip_template = HTML.tag('template', c=[tooltip],
**{'#content': 1})
tooltip_template = tooltip_template.replace(
HTML.literal('<template #content="1"'),
HTML.literal('<template #content'))
tooltip = HTML.tag('b-tooltip',
type='is-white',
size='is-large',
multilined='multilined',
c=[icon, tooltip_template])
label_contents.append(HTML.literal('&nbsp; &nbsp;'))
label_contents.append(tooltip)
# add 'configure' icon if allowed
if self.can_edit_help:
icon = HTML.tag('b-icon', size='is-small', pack='fas',
icon='cog')
icon = HTML.tag('a', title="Configure field", c=[icon],
**{'@click.prevent': "configureFieldInit('{}')".format(fieldname),
'v-show': 'configureFieldsHelp'})
label_contents.append(HTML.literal('&nbsp; &nbsp;'))
label_contents.append(icon)
# only declare label template if it's complex
html = [html]
# TODO: figure out why complex label does not work for oruga
if self.request.use_oruga:
attrs['label'] = label
else:
if len(label_contents) > 1:
# nb. must apply hack to get <template #label> as final result
label_template = HTML.tag('template', c=label_contents,
**{'#label': 1})
label_template = label_template.replace(
HTML.literal('<template #label="1"'),
HTML.literal('<template #label'))
html.insert(0, label_template)
else: # simple label
attrs['label'] = label
html = field.serialize(use_buefy=True,
**self.get_renderer_kwargs(fieldname))
# TODO: why do we not get HTML literal from serialize() ?
html = HTML.literal(html)
# and finally wrap it all in a <b-field>
return HTML.tag('b-field', c=html, **attrs)
return HTML.tag('b-field', c=[html], **attrs)
elif field: # hidden field
else: # hidden field
# can just do normal thing for these
# TODO: again, why does serialize() not return literal?
return HTML.literal(field.serialize())
# TODO: this was copied from wuttaweb; can remove when we align
# Form class structure
def render_vue_finalize(self):
""" """
set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}"
make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})"
return HTML.tag('script', c=['\n',
HTML.literal(set_data),
'\n',
HTML.literal(make_component),
'\n'])
def render_field_readonly(self, field_name, **kwargs):
"""
Render the given field completely, but in read-only fashion.
@ -1246,30 +937,20 @@ class Form(object):
if field_name not in self.fields:
return ''
# TODO: fair bit of duplication here, should merge with deform.mako
label = kwargs.get('label')
if not label:
label = self.get_label(field_name)
label = HTML.tag('label', label, for_=field_name)
field = self.render_field_value(field_name) or ''
field_div = HTML.tag('div', class_='field', c=[field])
contents = [label, field_div]
value = self.render_field_value(field_name) or ''
if self.has_helptext(field_name):
contents.append(HTML.tag('span', class_='instructions',
c=[self.render_helptext(field_name)]))
if not self.request.use_oruga:
label = HTML.tag('label', label, for_=field_name)
field_div = HTML.tag('div', class_='field', c=[value])
contents = [label, field_div]
if self.has_helptext(field_name):
contents.append(HTML.tag('span', class_='instructions',
c=[self.render_helptext(field_name)]))
return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
# nb. for some reason we must wrap once more for oruga,
# otherwise it splits up the field?!
value = HTML.tag('span', c=[value])
# oruga uses <o-field>
return HTML.tag('o-field', label=label, c=[value], **{':horizontal': 'true'})
return HTML.tag('div', class_='field-wrapper {}'.format(field_name), c=contents)
def render_field_value(self, field_name):
record = self.model_instance
@ -1281,7 +962,7 @@ class Form(object):
value = self.obtain_value(record, field_name)
if value is None:
return ""
return str(value)
return six.text_type(value)
def render_datetime(self, record, field_name):
value = self.obtain_value(record, field_name)
@ -1293,8 +974,7 @@ class Form(object):
value = self.obtain_value(record, field_name)
if value is None:
return ""
app = self.request.rattail_config.get_app()
value = app.localtime(value)
value = localtime(self.request.rattail_config, value)
return raw_datetime(self.request.rattail_config, value)
def render_duration(self, record, field_name):
@ -1317,14 +997,13 @@ class Form(object):
return "(${:0,.2f})".format(0 - value)
return "${:0,.2f}".format(value)
except ValueError:
return str(value)
return six.text_type(value)
def render_quantity(self, obj, field):
value = self.obtain_value(obj, field)
if value is None:
return ""
app = self.request.rattail_config.get_app()
return app.render_quantity(value)
return pretty_quantity(value)
def render_percent(self, obj, field):
app = self.request.rattail_config.get_app()
@ -1343,8 +1022,8 @@ class Form(object):
return ""
enum = self.enums.get(field_name)
if enum and value in enum:
return str(enum[value])
return str(value)
return six.text_type(enum[value])
return six.text_type(value)
def render_codeblock(self, record, field_name):
value = self.obtain_value(record, field_name)
@ -1375,70 +1054,83 @@ class Form(object):
def obtain_value(self, record, field_name):
if record:
if isinstance(record, dict):
return record[field_name]
try:
return getattr(record, field_name)
except AttributeError:
pass
try:
return record[field_name]
except TypeError:
pass
return getattr(record, field_name, None)
# TODO: is this always safe to do?
elif self.defaults and field_name in self.defaults:
return self.defaults[field_name]
def validate(self, *args, **kwargs):
"""
Try to validate the form.
if kwargs.pop('newstyle', False):
# yay, new behavior!
if hasattr(self, 'validated'):
del self.validated
if self.request.method != 'POST':
return False
This should work whether data was submitted as classic POST
data, or as JSON body.
controls = get_form_data(self.request).items()
:returns: ``True`` if form data is valid, otherwise ``False``.
"""
if 'newstyle' in kwargs:
warnings.warn("the `newstyle` kwarg is no longer used "
"for Form.validate()",
DeprecationWarning, stacklevel=2)
# unfortunately the normal form logic (i.e. peppercorn) is
# expecting all values to be strings, whereas if our data
# came from JSON body, may have given us some Pythonic
# objects. so here we must convert them *back* to strings
# TODO: this seems like a hack, i must be missing something
# TODO: also this uses same "JSON" check as get_form_data()
if self.request.is_xhr and not self.request.POST:
controls = [[key, val] for key, val in controls]
for i in range(len(controls)):
key, value = controls[i]
if value is None:
controls[i][1] = ''
elif value is True:
controls[i][1] = 'true'
elif value is False:
controls[i][1] = 'false'
elif not isinstance(value, six.string_types):
controls[i][1] = six.text_type(value)
if hasattr(self, 'validated'):
del self.validated
if self.request.method != 'POST':
return False
dform = self.make_deform_form()
try:
self.validated = dform.validate(controls)
return True
except deform.ValidationFailure:
return False
controls = get_form_data(self.request).items()
else: # legacy behavior
raise_error = kwargs.pop('raise_error', True)
dform = self.make_deform_form()
try:
return dform.validate(*args, **kwargs)
except deform.ValidationFailure:
if raise_error:
raise
# unfortunately the normal form logic (i.e. peppercorn) is
# expecting all values to be strings, whereas if our data
# came from JSON body, may have given us some Pythonic
# objects. so here we must convert them *back* to strings
# TODO: this seems like a hack, i must be missing something
# TODO: also this uses same "JSON" check as get_form_data()
if self.request.is_xhr and not self.request.POST:
controls = [[key, val] for key, val in controls]
for i in range(len(controls)):
key, value = controls[i]
if value is None:
controls[i][1] = ''
elif value is True:
controls[i][1] = 'true'
elif value is False:
controls[i][1] = 'false'
elif not isinstance(value, str):
controls[i][1] = str(value)
dform = self.make_deform_form()
try:
self.validated = dform.validate(controls)
return True
except deform.ValidationFailure:
return False
class FieldList(list):
"""
Convenience wrapper for a form's field list.
"""
def insert_before(self, field, newfield):
if field in self:
i = self.index(field)
self.insert(i, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def insert_after(self, field, newfield):
if field in self:
i = self.index(field)
self.insert(i + 1, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
@colander.deferred

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2019 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,8 @@
Forms for Receiving
"""
from __future__ import unicode_literals, absolute_import
from rattail.db import model
import colander
@ -33,7 +35,7 @@ import colander
def valid_purchase_batch_row(node, kw):
session = kw['session']
def validate(node, value):
row = session.get(model.PurchaseBatchRow, value)
row = session.query(model.PurchaseBatchRow).get(value)
if not row:
raise colander.Invalid(node, "Batch row not found")
if row.batch.executed:
@ -52,7 +54,6 @@ class ReceiveRow(colander.MappingSchema):
'received',
'damaged',
'expired',
'missing',
# 'mispick',
]))

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2019 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,9 +24,12 @@
Form Schema Types
"""
from __future__ import unicode_literals, absolute_import
import re
import datetime
import json
import six
from rattail.db import model
from rattail.gpc import GPC
@ -34,7 +37,6 @@ from rattail.gpc import GPC
import colander
from tailbone.db import Session
from tailbone.forms import widgets
class JQueryTime(colander.Time):
@ -74,76 +76,6 @@ class DateTimeBoolean(colander.Boolean):
return datetime.datetime.utcnow()
class FalafelDateTime(colander.DateTime):
"""
Custom schema node type for rattail UTC datetimes
"""
widget_maker = widgets.FalafelDateTimeWidget
def __init__(self, *args, **kwargs):
request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.request = request
def serialize(self, node, appstruct):
if not appstruct:
return {}
# cant use isinstance; dt subs date
if type(appstruct) is datetime.date:
appstruct = datetime.datetime.combine(appstruct, datetime.time())
if not isinstance(appstruct, datetime.datetime):
raise colander.Invalid(node, f'"{appstruct}" is not a datetime object')
if appstruct.tzinfo is None:
appstruct = appstruct.replace(tzinfo=self.default_tzinfo)
app = self.request.rattail_config.get_app()
dt = app.localtime(appstruct, from_utc=True)
return {
'date': str(dt.date()),
'time': str(dt.time()),
}
def deserialize(self, node, cstruct):
if not cstruct:
return colander.null
if not cstruct['date'] and not cstruct['time']:
return colander.null
try:
date = datetime.datetime.strptime(cstruct['date'], '%Y-%m-%d').date()
except:
node.raise_invalid("Missing or invalid date")
try:
time = datetime.datetime.strptime(cstruct['time'], '%H:%M:%S').time()
except:
node.raise_invalid("Missing or invalid time")
result = datetime.datetime.combine(date, time)
app = self.request.rattail_config.get_app()
result = app.localtime(result)
result = app.make_utc(result)
return result
class FalafelTime(colander.Time):
"""
Custom schema node type for simple time fields
"""
widget_maker = widgets.FalafelTimeWidget
def __init__(self, *args, **kwargs):
request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.request = request
class GPCType(colander.SchemaType):
"""
Schema type for product GPC data.
@ -152,7 +84,7 @@ class GPCType(colander.SchemaType):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return str(appstruct)
return six.text_type(appstruct)
def deserialize(self, node, cstruct):
if not cstruct:
@ -163,7 +95,7 @@ class GPCType(colander.SchemaType):
try:
return GPC(digits)
except Exception as err:
raise colander.Invalid(node, str(err))
raise colander.Invalid(node, six.text_type(err))
class ProductQuantity(colander.MappingSchema):
@ -201,12 +133,12 @@ class ModelType(colander.SchemaType):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return str(appstruct)
return six.text_type(appstruct)
def deserialize(self, node, cstruct):
if not cstruct:
return None
obj = self.session.get(self.model_class, cstruct)
obj = self.session.query(self.model_class).get(cstruct)
if not obj:
raise colander.Invalid(node, "{} not found".format(self.model_title))
return obj

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,16 +24,20 @@
Form Widgets
"""
from __future__ import unicode_literals, absolute_import, division
import json
import datetime
import decimal
import re
import six
import colander
from deform import widget as dfwidget
from webhelpers2.html import tags, HTML
from tailbone.db import Session
from tailbone.forms.types import ProductQuantity
class ReadonlyWidget(dfwidget.HiddenWidget):
@ -41,7 +45,6 @@ class ReadonlyWidget(dfwidget.HiddenWidget):
readonly = True
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
# TODO: is this hacky?
@ -58,11 +61,11 @@ class NumberInputWidget(dfwidget.TextInputWidget):
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.
This widget only supports Buefy themes for now. It 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
@ -79,17 +82,15 @@ class PercentInputWidget(dfwidget.TextInputWidget):
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)
cstruct = six.text_type(value)
return super(PercentInputWidget, self).serialize(field, cstruct, **kw)
def deserialize(self, field, pstruct):
""" """
pstruct = super().deserialize(field, pstruct)
pstruct = super(PercentInputWidget, self).deserialize(field, pstruct)
if pstruct is colander.null:
return colander.null
# convert "human-friendly" value to "traditional"
@ -99,7 +100,7 @@ class PercentInputWidget(dfwidget.TextInputWidget):
raise colander.Invalid(field.schema, "Invalid decimal string: {}".format(pstruct))
value = value.quantize(decimal.Decimal('0.00001'))
value /= 100
return str(value)
return six.text_type(value)
class CasesUnitsWidget(dfwidget.Widget):
@ -112,7 +113,6 @@ class CasesUnitsWidget(dfwidget.Widget):
one_amount_only = False
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
readonly = kw.get('readonly', self.readonly)
@ -123,9 +123,6 @@ class CasesUnitsWidget(dfwidget.Widget):
return field.renderer(template, **values)
def deserialize(self, field, pstruct):
""" """
from tailbone.forms.types import ProductQuantity
if pstruct is colander.null:
return colander.null
@ -154,7 +151,6 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
template = 'checkbox_dynamic'
# TODO: deprecate / remove this
class PlainSelectWidget(dfwidget.SelectWidget):
template = 'select_plain'
@ -173,7 +169,7 @@ class CustomSelectWidget(dfwidget.SelectWidget):
self.extra_template_values.update(kw)
def get_template_values(self, field, cstruct, kw):
values = super().get_template_values(field, cstruct, kw)
values = super(CustomSelectWidget, self).get_template_values(field, cstruct, kw)
if hasattr(self, 'extra_template_values'):
values.update(self.extra_template_values)
return values
@ -216,7 +212,6 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
)
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
readonly = kw.get('readonly', self.readonly)
@ -244,48 +239,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):
"""
Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
@ -308,7 +261,6 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
options = None
def serialize(self, field, cstruct, **kw):
""" """
if 'delay' in kw or getattr(self, 'delay', None):
raise ValueError(
'AutocompleteWidget does not support *delay* parameter '
@ -337,114 +289,6 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
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
@ -469,16 +313,13 @@ def make_customer_widget(request, **kwargs):
class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
"""
Autocomplete widget for a
:class:`~rattail:rattail.db.model.customers.Customer` reference
field.
Autocomplete widget for a Customer reference field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs)
self.request = request
app = self.request.rattail_config.get_app()
model = app.model
model = self.request.rattail_config.get_model()
# must figure out URL providing autocomplete service
if 'service_url' not in kwargs:
@ -496,30 +337,26 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
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)
model = self.request.rattail_config.get_model()
customer = Session.query(model.Customer).get(cstruct)
if customer:
self.field_display = str(customer)
self.field_display = six.text_type(customer)
return super().serialize(
return super(CustomerAutocompleteWidget, self).serialize(
field, cstruct, **kw)
class CustomerDropdownWidget(dfwidget.SelectWidget):
"""
Dropdown widget for a
:class:`~rattail:rattail.db.model.customers.Customer` reference
field.
Dropdown widget for a Customer reference field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
super(CustomerDropdownWidget, self).__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:
@ -531,8 +368,10 @@ class CustomerDropdownWidget(dfwidget.SelectWidget):
customers = customers()
else: # default customer list
customers = app.get_clientele_handler()\
.get_all_customers(Session())
model = self.request.rattail_config.get_model()
customers = Session.query(model.Customer)\
.order_by(model.Customer.name)\
.all()
# convert customer list to option values
self.values = [(c.uuid, c.name)
@ -554,106 +393,13 @@ class DepartmentWidget(dfwidget.SelectWidget):
def __init__(self, request, **kwargs):
if 'values' not in kwargs:
app = request.rattail_config.get_app()
model = app.model
model = request.rattail_config.get_model()
departments = Session.query(model.Department)\
.order_by(model.Department.number)
values = [(dept.uuid, str(dept))
values = [(dept.uuid, six.text_type(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]
super(DepartmentWidget, self).__init__(**kwargs)

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,15 +24,17 @@
Grid Filters
"""
from __future__ import unicode_literals, absolute_import
import re
import datetime
import decimal
import logging
from collections import OrderedDict
import six
import sqlalchemy as sa
from rattail.gpc import GPC
from rattail.util import OrderedDict
from rattail.core import UNSPECIFIED
from rattail.time import localtime, make_utc
from rattail.util import prettify
@ -115,7 +117,7 @@ class EnumValueRenderer(ChoiceValueRenderer):
sorted_keys = list(enum.keys())
else:
sorted_keys = sorted(enum, key=lambda k: enum[k].lower())
self.options = [tags.Option(enum[k], str(k)) for k in sorted_keys]
self.options = [tags.Option(enum[k], six.text_type(k)) for k in sorted_keys]
class GridFilter(object):
@ -171,25 +173,18 @@ class GridFilter(object):
data_type = 'string' # default, but will be set from value renderer
choices = {}
def __init__(self, key, config=None, label=None, verbs=None,
value_enum=None, value_renderer=None,
def __init__(self, key, label=None, verbs=None, value_enum=None, value_renderer=None,
default_active=False, default_verb=None, default_value=None,
encode_values=False, value_encoding='utf-8', **kwargs):
self.key = key
self.config = config
self.label = label or prettify(key)
self.verbs = verbs or self.get_default_verbs()
if value_renderer:
self.set_value_renderer(value_renderer)
elif value_enum:
self.set_choices(value_enum)
else:
self.set_value_renderer(self.value_renderer_factory)
# nb. do this after setting choices, if applicable, since that
# could change default verbs
self.verbs = verbs or self.get_default_verbs()
self.default_active = default_active
self.default_verb = default_verb
self.default_value = default_value
@ -277,15 +272,14 @@ class GridFilter(object):
value = self.get_value(value)
filtr = getattr(self, 'filter_{0}'.format(verb), None)
if not filtr:
log.warning("unknown filter verb: %s", verb)
return data
raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
return filtr(data, value)
def get_value(self, value=UNSPECIFIED):
return value if value is not UNSPECIFIED else self.value
def encode_value(self, value):
if self.encode_values and isinstance(value, str):
if self.encode_values and isinstance(value, six.string_types):
return value.encode('utf-8')
return value
@ -314,7 +308,7 @@ class AlchemyGridFilter(GridFilter):
def __init__(self, *args, **kwargs):
self.column = kwargs.pop('column')
super().__init__(*args, **kwargs)
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
def filter_equal(self, query, value):
"""
@ -338,38 +332,6 @@ class AlchemyGridFilter(GridFilter):
self.column != self.encode_value(value),
))
def filter_equal_any_of(self, query, value):
"""
This filter expects "multiple values" separated by newline
character, and will add an "OR" condition with each value
being checked separately. For instance if the user submits a
"value" like this:
.. code-block:: none
foo bar
baz
This will result in SQL condition like this:
.. code-block:: sql
name = 'foo bar' OR name = 'baz'
"""
if not value:
return query
values = value.split('\n')
values = [value for value in values if value]
if not values:
return query
conditions = []
for value in values:
conditions.append(self.column == self.encode_value(value))
return query.filter(sa.or_(*conditions))
def filter_is_null(self, query, value):
"""
Filter data with an 'IS NULL' query. Note that this filter does not
@ -448,12 +410,12 @@ class AlchemyGridFilter(GridFilter):
if start_value:
if self.value_invalid(start_value):
return query
query = query.filter(self.column >= self.encode_value(start_value))
query = query.filter(self.column >= start_value)
if end_value:
if self.value_invalid(end_value):
return query
query = query.filter(self.column <= self.encode_value(end_value))
query = query.filter(self.column <= end_value)
return query
@ -467,13 +429,9 @@ class AlchemyStringFilter(AlchemyGridFilter):
"""
Expose contains / does-not-contain verbs in addition to core.
"""
if self.choices:
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
return ['contains', 'does_not_contain',
'contains_any_of',
'equal', 'not_equal', 'equal_any_of',
'equal', 'not_equal',
'is_empty', 'is_not_empty',
'is_null', 'is_not_null',
'is_empty_or_null',
@ -485,13 +443,9 @@ class AlchemyStringFilter(AlchemyGridFilter):
"""
if value is None or value == '':
return query
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = self.encode_value(f'%{val}%')
criteria.append(self.column.ilike(val))
return query.filter(sa.and_(*criteria))
return query.filter(sa.and_(
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]))
def filter_does_not_contain(self, query, value):
"""
@ -500,17 +454,14 @@ class AlchemyStringFilter(AlchemyGridFilter):
if value is None or value == '':
return query
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = self.encode_value(f'%{val}%')
criteria.append(~self.column.ilike(val))
# When saying something is 'not like' something else, we must also
# include things which are nothing at all, in our result set.
return query.filter(sa.or_(
self.column == None,
sa.and_(*criteria)))
sa.and_(
*[~self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]),
))
def filter_contains_any_of(self, query, value):
"""
@ -539,28 +490,24 @@ class AlchemyStringFilter(AlchemyGridFilter):
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))
conditions.append(sa.and_(
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]))
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(''))
return query.filter(sa.func.trim(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(''))
return query.filter(sa.func.trim(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(''),
sa.func.trim(self.column) == self.encode_value(''),
self.column == None))
class AlchemyEmptyStringFilter(AlchemyStringFilter):
"""
String filter with special logic to treat empty string values as NULL
@ -570,13 +517,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter):
return query.filter(
sa.or_(
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):
return query.filter(
sa.and_(
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):
@ -588,8 +535,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
value_encoding = 'utf-8'
def get_value(self, value=UNSPECIFIED):
value = super().get_value(value)
if isinstance(value, str):
value = super(AlchemyByteStringFilter, self).get_value(value)
if isinstance(value, six.text_type):
value = value.encode(self.value_encoding)
return value
@ -599,13 +546,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
"""
if value is None or value == '':
return query
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = b'%{}%'.format(val)
criteria.append(self.column.ilike(val))
return query.filters(sa.and_(*criteria))
return query.filter(sa.and_(
*[self.column.ilike(b'%{}%'.format(v)) for v in value.split()]))
def filter_does_not_contain(self, query, value):
"""
@ -614,16 +556,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
if value is None or value == '':
return query
for val in value.split():
val = val.replace('_', '\_')
val = b'%{}%'.format(val)
criteria.append(~self.column.ilike(val))
# When saying something is 'not like' something else, we must also
# include things which are nothing at all, in our result set.
return query.filter(sa.or_(
self.column == None,
sa.and_(*criteria)))
sa.and_(
*[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]),
))
class AlchemyNumericFilter(AlchemyGridFilter):
@ -632,11 +571,10 @@ class AlchemyNumericFilter(AlchemyGridFilter):
"""
value_renderer_factory = NumericValueRenderer
def default_verbs(self):
# expose greater-than / less-than verbs in addition to core
return ['equal', 'not_equal', 'greater_than', 'greater_equal',
'less_than', 'less_equal', 'between',
'is_null', 'is_not_null', 'is_any']
# expose greater-than / less-than verbs in addition to core
default_verbs = ['equal', 'not_equal', 'greater_than', 'greater_equal',
'less_than', 'less_equal', 'between',
'is_null', 'is_not_null', 'is_any']
# TODO: what follows "works" in that it prevents an error...but from the
# user's perspective it still fails silently...need to improve on front-end
@ -648,66 +586,47 @@ class AlchemyNumericFilter(AlchemyGridFilter):
# first just make sure it's somewhat numeric
try:
self.parse_decimal(value)
except decimal.InvalidOperation:
float(value)
except ValueError:
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)
return bool(value and len(six.text_type(value)) > 8)
def filter_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_equal(query, value)
return super(AlchemyNumericFilter, self).filter_equal(query, value)
def filter_not_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_not_equal(query, value)
return super(AlchemyNumericFilter, self).filter_not_equal(query, value)
def filter_greater_than(self, query, value):
if self.value_invalid(value):
return query
return super().filter_greater_than(query, value)
return super(AlchemyNumericFilter, self).filter_greater_than(query, value)
def filter_greater_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_greater_equal(query, value)
return super(AlchemyNumericFilter, self).filter_greater_equal(query, value)
def filter_less_than(self, query, value):
if self.value_invalid(value):
return query
return super().filter_less_than(query, value)
return super(AlchemyNumericFilter, self).filter_less_than(query, value)
def filter_less_equal(self, query, value):
if self.value_invalid(value):
return query
return super().filter_less_equal(query, value)
return super(AlchemyNumericFilter, self).filter_less_equal(query, value)
class AlchemyIntegerFilter(AlchemyNumericFilter):
"""
Integer filter for SQLAlchemy.
"""
bigint = False
def default_verbs(self):
# limited verbs if choices are defined
if self.choices:
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
return super().default_verbs()
def value_invalid(self, value):
if value:
@ -715,10 +634,9 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
return True
if not value.isdigit():
return True
# normal Integer columns have a max value, beyond which PG
# will throw an error if we try to query for larger values
# TODO: this seems hacky, how to better handle it?
if not self.bigint and int(value) > 2147483647:
# TODO: this one is to avoid DataError from PG, but perhaps that
# isn't a good enough reason to make this global logic?
if int(value) > 2147483647:
return True
return False
@ -728,13 +646,6 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
return int(value)
class AlchemyBigIntegerFilter(AlchemyIntegerFilter):
"""
BigInteger filter for SQLAlchemy.
"""
bigint = True
class AlchemyBooleanFilter(AlchemyGridFilter):
"""
Boolean filter for SQLAlchemy.
@ -1223,7 +1134,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
'ILIKE' query with those parts.
"""
value = self.parse_value(value)
return super().filter_contains(query, value)
return super(AlchemyPhoneNumberFilter, self).filter_contains(query, value)
def filter_does_not_contain(self, query, value):
"""
@ -1231,7 +1142,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
'NOT ILIKE' query with those parts.
"""
value = self.parse_value(value)
return super().filter_does_not_contain(query, value)
return super(AlchemyPhoneNumberFilter, self).filter_does_not_contain(query, value)
class GridFilterSet(OrderedDict):
@ -1275,7 +1186,7 @@ class GridFiltersForm(forms.Form):
node = colander.SchemaNode(colander.String(), name=key)
schema.add(node)
kwargs['schema'] = schema
super().__init__(**kwargs)
super(GridFiltersForm, self).__init__(**kwargs)
def iter_filters(self):
return self.filters.values()

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
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2021 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,20 +24,22 @@
Template Context Helpers
"""
# start off with all from wuttaweb
from wuttaweb.helpers import *
from __future__ import unicode_literals, absolute_import
import os
import datetime
from decimal import Decimal
from collections import OrderedDict
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, hours_as_decimal,
OrderedDict)
from rattail.db.util import maxlen
from tailbone.util import (pretty_datetime, raw_datetime,
render_markdown,
from webhelpers2.html import *
from webhelpers2.html.tags import *
from tailbone.util import (csrf_token, get_csrf_token,
pretty_datetime, raw_datetime,
route_exists)

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -48,9 +48,6 @@ class TailboneProvider(object):
def get_provided_views(self):
return {}
def make_integration_menu(self, request, **kwargs):
pass
def get_all_providers(config):
"""

View file

@ -111,7 +111,7 @@
<td class="brand">${cost.product.brand or ''}</td>
<td class="desc">${cost.product.description}</td>
<td class="size">${cost.product.size or ''}</td>
<td class="case-qty">${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="preferred">${'X' if cost.preference == 1 else ''}</td>
% for i in range(14):

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8; -*-
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
# Copyright © 2010-2017 Lance Edgar
#
# 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 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):
"""
API views for Label Profile data
"""
model_class = LabelProfile
collection_url_prefix = '/label-profiles'
object_url_prefix = '/label-profile'
class RattailTemplate(PyramidTemplate):
_template_dir = resource_path('rattail:data/project')
summary = "Starter project based on Rattail / Tailbone"
def defaults(config, **kwargs):
base = globals()
LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView'])
LabelProfileView.defaults(config)
def includeme(config):
defaults(config)
def pre(self, command, output_dir, vars):
"""
Adds some more variables to the template context.
"""
vars['project_title'] = prettify(vars['project'])
vars['package_title'] = vars['package'].capitalize()
return super(RattailTemplate, self).pre(command, output_dir, vars)

View file

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

View file

@ -1,14 +1,122 @@
/******************************
* 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;
}
pre.is-family-sans-serif {
background-color: white;
font-family: Verdana, Arial, sans-serif;
font-size: 11pt;
padding: 1em;
}
/******************************
* jQuery UI tweaks
******************************/
ul.ui-menu {
max-height: 30em;
}
/******************************
* tweaks for root user
******************************/
.navbar .navbar-end .navbar-link.root-user,
.navbar .navbar-end .navbar-link.root-user:hover,
.navbar .navbar-end .navbar-link.root-user.is_active,
.navbar .navbar-end .navbar-item.root-user,
.navbar .navbar-end .navbar-item.root-user:hover,
.navbar .navbar-end .navbar-item.root-user.is_active {
.menubar .root-user .ui-button-text,
.menubar .root-user.ui-menu-item a {
background-color: red;
color: black;
font-weight: bold;
}
.menubar .root-user.ui-menu-item a {
padding-left: 1em;
}

View file

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

View file

@ -1,37 +1,34 @@
/******************************
* forms
* Form Wrapper
******************************/
/* note that this should only apply to "normal" primary forms */
/* TODO: replace this with bulma equivalent */
div.form-wrapper {
overflow: auto;
}
/******************************
* Forms
******************************/
div.form,
div.fieldset-form,
div.fieldset {
clear: left;
float: left;
margin-top: 10px;
}
.form {
padding-left: 5em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-label .label {
text-align: left;
white-space: nowrap;
width: 18em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-body {
min-width: 30em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-body .select,
.form-wrapper .form .field.is-horizontal .field-body .select select {
width: 100%;
}
/******************************
* field-wrappers
* Fieldsets
******************************/
/* TODO: replace this with bulma equivalent */
.field-wrapper {
clear: both;
min-height: 30px;
@ -39,12 +36,16 @@
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 {
display: table-row;
}
/* TODO: replace this with bulma equivalent */
.field-wrapper label {
display: table-cell;
vertical-align: top;
@ -54,8 +55,47 @@
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 {
display: table-cell;
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;
}

View file

@ -25,11 +25,6 @@
margin: 0;
}
.grid-tools {
display: flex;
gap: 0.5rem;
}
.grid-wrapper .grid-header td.tools {
margin: 0;
padding: 0;
@ -266,10 +261,6 @@
* main actions
******************************/
a.grid-action {
white-space: nowrap;
}
.grid .actions {
width: 1px;
}

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,87 +1,152 @@
/******************************
* main layout
* Main Layout
******************************/
body {
display: flex;
flex-direction: column;
min-height: 100vh;
html, body, #body-wrapper {
height: 100%;
}
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 div.login {
float: right;
}
/* 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 .global .after-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 {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
height: 100%;
padding-bottom: 30px;
}
/******************************
* 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;
#scrollpane {
height: 100%;
}
header .level {
/* TODO: not sure what this 60px was supposed to do? but it broke the */
/* styles for the feedback dialog, so disabled it is.
/* height: 60px; */
/* line-height: 60px; */
padding-left: 0.5em;
padding-right: 0.5em;
#scrollpane .inner-content {
padding: 0 0.5em 0.5em 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 {
margin-bottom: 1em;
margin-left: 1em;
list-style-type: none;
margin: 0.5em;
text-align: right;
white-space: nowrap;
}
@ -90,24 +155,11 @@ header span.header-text {
* "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;
min-width: 20em;
}
.object-helper-content {
@ -115,44 +167,87 @@ header span.header-text {
}
/******************************
* markdown
* Panels
******************************/
.rendered-markdown p,
.rendered-markdown ul {
margin-bottom: 1rem;
.panel,
.panel-grid {
border-left: 1px solid Black;
margin-bottom: 1em;
}
.rendered-markdown .codehilite {
margin-bottom: 2rem;
.panel {
border-bottom: 1px solid Black;
border-right: 1px solid Black;
padding: 0px;
}
/******************************
* fix datepicker within modals
* TODO: someday this may not be necessary? cf.
* https://github.com/buefy/buefy/issues/292#issuecomment-347365637
******************************/
.modal .animation-content .modal-card {
overflow: visible !important;
.panel h2,
.panel-grid h2 {
border-bottom: 1px solid Black;
border-top: 1px solid Black;
padding: 5px;
margin: 0px;
}
.modal-card-body {
overflow: visible !important;
.panel-grid h2 {
border-right: 1px solid Black;
}
/* TODO: a simpler option we might try sometime instead? */
/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */
.panel-body {
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-dialog .red {
#feedback-dialog {
display: none;
}
#feedback-dialog p {
margin-top: 1em;
}
#feedback-dialog .red {
color: red;
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,48 @@
/******************************
* login.css
******************************/
.logo img,
#logo {
display: block;
margin: 40px auto;
max-height: 350px;
max-width: 800px;
}
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 {
display: block;
}
div.buttons input {
margin: auto 5px;
}
/* this is for "login as chuck" tip in demo mode */
.tips {
margin-top: 2em;
text-align: center;
}

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[data-datepicker="true"]').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,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);

View file

@ -0,0 +1,331 @@
/*
* jQuery UI Menubar @VERSION
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Menubar
*
* Depends:
* jquery.ui.core.js
* jquery.ui.widget.js
* jquery.ui.position.js
* jquery.ui.menu.js
*/
(function( $ ) {
// TODO when mixing clicking menus and keyboard navigation, focus handling is broken
// there has to be just one item that has tabindex
$.widget( "ui.menubar", {
version: "@VERSION",
options: {
autoExpand: false,
buttons: false,
items: "li",
menuElement: "ul",
menuIcon: false,
position: {
my: "left top",
at: "left bottom"
}
},
_create: function() {
var that = this;
this.menuItems = this.element.children( this.options.items );
this.items = this.menuItems.children( "button, a" );
this.menuItems
.addClass( "ui-menubar-item" )
.attr( "role", "presentation" );
// let only the first item receive focus
this.items.slice(1).attr( "tabIndex", -1 );
this.element
.addClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
.attr( "role", "menubar" );
this._focusable( this.items );
this._hoverable( this.items );
this.items.siblings( this.options.menuElement )
.menu({
position: {
within: this.options.position.within
},
select: function( event, ui ) {
ui.item.parents( "ul.ui-menu:last" ).hide();
that._close();
// TODO what is this targetting? there's probably a better way to access it
$(event.target).prev().focus();
that._trigger( "select", event, ui );
},
menus: that.options.menuElement
})
.hide()
.attr({
"aria-hidden": "true",
"aria-expanded": "false"
})
// TODO use _on
.bind( "keydown.menubar", function( event ) {
var menu = $( this );
if ( menu.is( ":hidden" ) ) {
return;
}
switch ( event.keyCode ) {
case $.ui.keyCode.LEFT:
that.previous( event );
event.preventDefault();
break;
case $.ui.keyCode.RIGHT:
that.next( event );
event.preventDefault();
break;
}
});
this.items.each(function() {
var input = $(this),
// TODO menu var is only used on two places, doesn't quite justify the .each
menu = input.next( that.options.menuElement );
// might be a non-menu button
if ( menu.length ) {
// TODO use _on
input.bind( "click.menubar focus.menubar mouseenter.menubar", function( event ) {
// ignore triggered focus event
if ( event.type === "focus" && !event.originalEvent ) {
return;
}
event.preventDefault();
// TODO can we simplify or extractthis check? especially the last two expressions
// there's a similar active[0] == menu[0] check in _open
if ( event.type === "click" && menu.is( ":visible" ) && that.active && that.active[0] === menu[0] ) {
that._close();
return;
}
if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" || that.options.autoExpand ) {
if( that.options.autoExpand ) {
clearTimeout( that.closeTimer );
}
that._open( event, menu );
}
})
// TODO use _on
.bind( "keydown", function( event ) {
switch ( event.keyCode ) {
case $.ui.keyCode.SPACE:
case $.ui.keyCode.UP:
case $.ui.keyCode.DOWN:
that._open( event, $( this ).next() );
event.preventDefault();
break;
case $.ui.keyCode.LEFT:
that.previous( event );
event.preventDefault();
break;
case $.ui.keyCode.RIGHT:
that.next( event );
event.preventDefault();
break;
}
})
.attr( "aria-haspopup", "true" );
// TODO review if these options (menuIcon and buttons) are a good choice, maybe they can be merged
if ( that.options.menuIcon ) {
input.addClass( "ui-state-default" ).append( "<span class='ui-button-icon-secondary ui-icon ui-icon-triangle-1-s'></span>" );
input.removeClass( "ui-button-text-only" ).addClass( "ui-button-text-icon-secondary" );
}
} else {
// TODO use _on
input.bind( "click.menubar mouseenter.menubar", function( event ) {
if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" ) {
that._close();
}
});
}
input
.addClass( "ui-button ui-widget ui-button-text-only ui-menubar-link" )
.attr( "role", "menuitem" )
.wrapInner( "<span class='ui-button-text'></span>" );
if ( that.options.buttons ) {
input.removeClass( "ui-menubar-link" ).addClass( "ui-state-default" );
}
});
that._on( {
keydown: function( event ) {
if ( event.keyCode === $.ui.keyCode.ESCAPE && that.active && that.active.menu( "collapse", event ) !== true ) {
var active = that.active;
that.active.blur();
that._close( event );
active.prev().focus();
}
},
focusin: function( event ) {
clearTimeout( that.closeTimer );
},
focusout: function( event ) {
that.closeTimer = setTimeout( function() {
that._close( event );
}, 150);
},
"mouseleave .ui-menubar-item": function( event ) {
if ( that.options.autoExpand ) {
that.closeTimer = setTimeout( function() {
that._close( event );
}, 150);
}
},
"mouseenter .ui-menubar-item": function( event ) {
clearTimeout( that.closeTimer );
}
});
// Keep track of open submenus
this.openSubmenus = 0;
},
_destroy : function() {
this.menuItems
.removeClass( "ui-menubar-item" )
.removeAttr( "role" );
this.element
.removeClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
.removeAttr( "role" )
.unbind( ".menubar" );
this.items
.unbind( ".menubar" )
.removeClass( "ui-button ui-widget ui-button-text-only ui-menubar-link ui-state-default" )
.removeAttr( "role" )
.removeAttr( "aria-haspopup" )
// TODO unwrap?
.children( "span.ui-button-text" ).each(function( i, e ) {
var item = $( this );
item.parent().html( item.html() );
})
.end()
.children( ".ui-icon" ).remove();
this.element.find( ":ui-menu" )
.menu( "destroy" )
.show()
.removeAttr( "aria-hidden" )
.removeAttr( "aria-expanded" )
.removeAttr( "tabindex" )
.unbind( ".menubar" );
},
_close: function() {
if ( !this.active || !this.active.length ) {
return;
}
this.active
.menu( "collapseAll" )
.hide()
.attr({
"aria-hidden": "true",
"aria-expanded": "false"
});
this.active
.prev()
.removeClass( "ui-state-active" )
.removeAttr( "tabIndex" );
this.active = null;
this.open = false;
this.openSubmenus = 0;
},
_open: function( event, menu ) {
// on a single-button menubar, ignore reopening the same menu
if ( this.active && this.active[0] === menu[0] ) {
return;
}
// TODO refactor, almost the same as _close above, but don't remove tabIndex
if ( this.active ) {
this.active
.menu( "collapseAll" )
.hide()
.attr({
"aria-hidden": "true",
"aria-expanded": "false"
});
this.active
.prev()
.removeClass( "ui-state-active" );
}
// set tabIndex -1 to have the button skipped on shift-tab when menu is open (it gets focus)
var button = menu.prev().addClass( "ui-state-active" ).attr( "tabIndex", -1 );
this.active = menu
.show()
.position( $.extend({
of: button
}, this.options.position ) )
.removeAttr( "aria-hidden" )
.attr( "aria-expanded", "true" )
.menu("focus", event, menu.children( ".ui-menu-item" ).first() )
// TODO need a comment here why both events are triggered
// TODO: heh well given the above comment i'm not sure what the
// implications might be for disabling the focus() call..but it
// messes with text input focus in undesirable ways..so disable it
// we will..until we know why we shouldn't
// .focus()
.focusin();
this.open = true;
},
next: function( event ) {
if ( this.open && this.active.data( "menu" ).active.has( ".ui-menu" ).length ) {
// Track number of open submenus and prevent moving to next menubar item
this.openSubmenus++;
return;
}
this.openSubmenus = 0;
this._move( "next", "first", event );
},
previous: function( event ) {
if ( this.open && this.openSubmenus ) {
// Track number of open submenus and prevent moving to previous menubar item
this.openSubmenus--;
return;
}
this.openSubmenus = 0;
this._move( "prev", "last", event );
},
_move: function( direction, filter, event ) {
var next,
wrapItem;
if ( this.open ) {
next = this.active.closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).first().children( ".ui-menu" ).eq( 0 );
wrapItem = this.menuItems[ filter ]().children( ".ui-menu" ).eq( 0 );
} else {
if ( event ) {
next = $( event.target ).closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).children( ".ui-menubar-link" ).eq( 0 );
wrapItem = this.menuItems[ filter ]().children( ".ui-menubar-link" ).eq( 0 );
} else {
next = wrapItem = this.menuItems.children( "a" ).eq( 0 );
}
}
if ( next.length ) {
if ( this.open ) {
this._open( event, next );
} else {
next.removeAttr( "tabIndex")[0].focus();
}
} else {
if ( this.open ) {
this._open( event, wrapItem );
} else {
wrapItem.removeAttr( "tabIndex")[0].focus();
}
}
}
});
}( jQuery ));

File diff suppressed because it is too large Load diff

17
tailbone/static/js/lib/tag-it.min.js vendored Normal file
View file

@ -0,0 +1,17 @@
(function(b){b.widget("ui.tagit",{options:{allowDuplicates:!1,caseSensitive:!0,fieldName:"tags",placeholderText:null,readOnly:!1,removeConfirmation:!1,tagLimit:null,availableTags:[],autocomplete:{},showAutocompleteOnFocus:!1,allowSpaces:!1,singleField:!1,singleFieldDelimiter:",",singleFieldNode:null,animate:!0,tabIndex:null,beforeTagAdded:null,afterTagAdded:null,beforeTagRemoved:null,afterTagRemoved:null,onTagClicked:null,onTagLimitExceeded:null,onTagAdded:null,onTagRemoved:null,tagSource:null},_create:function(){var a=
this;this.element.is("input")?(this.tagList=b("<ul></ul>").insertAfter(this.element),this.options.singleField=!0,this.options.singleFieldNode=this.element,this.element.addClass("tagit-hidden-field")):this.tagList=this.element.find("ul, ol").andSelf().last();this.tagInput=b('<input type="text" />').addClass("ui-widget-content");this.options.readOnly&&this.tagInput.attr("disabled","disabled");this.options.tabIndex&&this.tagInput.attr("tabindex",this.options.tabIndex);this.options.placeholderText&&this.tagInput.attr("placeholder",
this.options.placeholderText);this.options.autocomplete.source||(this.options.autocomplete.source=function(a,e){var d=a.term.toLowerCase(),c=b.grep(this.options.availableTags,function(a){return 0===a.toLowerCase().indexOf(d)});this.options.allowDuplicates||(c=this._subtractArray(c,this.assignedTags()));e(c)});this.options.showAutocompleteOnFocus&&(this.tagInput.focus(function(b,d){a._showAutocomplete()}),"undefined"===typeof this.options.autocomplete.minLength&&(this.options.autocomplete.minLength=
0));b.isFunction(this.options.autocomplete.source)&&(this.options.autocomplete.source=b.proxy(this.options.autocomplete.source,this));b.isFunction(this.options.tagSource)&&(this.options.tagSource=b.proxy(this.options.tagSource,this));this.tagList.addClass("tagit").addClass("ui-widget ui-widget-content ui-corner-all").append(b('<li class="tagit-new"></li>').append(this.tagInput)).click(function(d){var c=b(d.target);c.hasClass("tagit-label")?(c=c.closest(".tagit-choice"),c.hasClass("removed")||a._trigger("onTagClicked",
d,{tag:c,tagLabel:a.tagLabel(c)})):a.tagInput.focus()});var c=!1;if(this.options.singleField)if(this.options.singleFieldNode){var d=b(this.options.singleFieldNode),f=d.val().split(this.options.singleFieldDelimiter);d.val("");b.each(f,function(b,d){a.createTag(d,null,!0);c=!0})}else this.options.singleFieldNode=b('<input type="hidden" style="display:none;" value="" name="'+this.options.fieldName+'" />'),this.tagList.after(this.options.singleFieldNode);c||this.tagList.children("li").each(function(){b(this).hasClass("tagit-new")||
(a.createTag(b(this).text(),b(this).attr("class"),!0),b(this).remove())});this.tagInput.keydown(function(c){if(c.which==b.ui.keyCode.BACKSPACE&&""===a.tagInput.val()){var d=a._lastTag();!a.options.removeConfirmation||d.hasClass("remove")?a.removeTag(d):a.options.removeConfirmation&&d.addClass("remove ui-state-highlight")}else a.options.removeConfirmation&&a._lastTag().removeClass("remove ui-state-highlight");if(c.which===b.ui.keyCode.COMMA&&!1===c.shiftKey||c.which===b.ui.keyCode.ENTER||c.which==
b.ui.keyCode.TAB&&""!==a.tagInput.val()||c.which==b.ui.keyCode.SPACE&&!0!==a.options.allowSpaces&&('"'!=b.trim(a.tagInput.val()).replace(/^s*/,"").charAt(0)||'"'==b.trim(a.tagInput.val()).charAt(0)&&'"'==b.trim(a.tagInput.val()).charAt(b.trim(a.tagInput.val()).length-1)&&0!==b.trim(a.tagInput.val()).length-1))c.which===b.ui.keyCode.ENTER&&""===a.tagInput.val()||c.preventDefault(),a.options.autocomplete.autoFocus&&a.tagInput.data("autocomplete-open")||(a.tagInput.autocomplete("close"),a.createTag(a._cleanedInput()))}).blur(function(b){a.tagInput.data("autocomplete-open")||
a.createTag(a._cleanedInput())});if(this.options.availableTags||this.options.tagSource||this.options.autocomplete.source)d={select:function(b,c){a.createTag(c.item.value);return!1}},b.extend(d,this.options.autocomplete),d.source=this.options.tagSource||d.source,this.tagInput.autocomplete(d).bind("autocompleteopen.tagit",function(b,c){a.tagInput.data("autocomplete-open",!0)}).bind("autocompleteclose.tagit",function(b,c){a.tagInput.data("autocomplete-open",!1)}),this.tagInput.autocomplete("widget").addClass("tagit-autocomplete")},
destroy:function(){b.Widget.prototype.destroy.call(this);this.element.unbind(".tagit");this.tagList.unbind(".tagit");this.tagInput.removeData("autocomplete-open");this.tagList.removeClass("tagit ui-widget ui-widget-content ui-corner-all tagit-hidden-field");this.element.is("input")?(this.element.removeClass("tagit-hidden-field"),this.tagList.remove()):(this.element.children("li").each(function(){b(this).hasClass("tagit-new")?b(this).remove():(b(this).removeClass("tagit-choice ui-widget-content ui-state-default ui-state-highlight ui-corner-all remove tagit-choice-editable tagit-choice-read-only"),
b(this).text(b(this).children(".tagit-label").text()))}),this.singleFieldNode&&this.singleFieldNode.remove());return this},_cleanedInput:function(){return b.trim(this.tagInput.val().replace(/^"(.*)"$/,"$1"))},_lastTag:function(){return this.tagList.find(".tagit-choice:last:not(.removed)")},_tags:function(){return this.tagList.find(".tagit-choice:not(.removed)")},assignedTags:function(){var a=this,c=[];this.options.singleField?(c=b(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter),
""===c[0]&&(c=[])):this._tags().each(function(){c.push(a.tagLabel(this))});return c},_updateSingleTagsField:function(a){b(this.options.singleFieldNode).val(a.join(this.options.singleFieldDelimiter)).trigger("change")},_subtractArray:function(a,c){for(var d=[],f=0;f<a.length;f++)-1==b.inArray(a[f],c)&&d.push(a[f]);return d},tagLabel:function(a){return this.options.singleField?b(a).find(".tagit-label:first").text():b(a).find("input:first").val()},_showAutocomplete:function(){this.tagInput.autocomplete("search",
"")},_findTagByLabel:function(a){var c=this,d=null;this._tags().each(function(f){if(c._formatStr(a)==c._formatStr(c.tagLabel(this)))return d=b(this),!1});return d},_isNew:function(a){return!this._findTagByLabel(a)},_formatStr:function(a){return this.options.caseSensitive?a:b.trim(a.toLowerCase())},_effectExists:function(a){return Boolean(b.effects&&(b.effects[a]||b.effects.effect&&b.effects.effect[a]))},createTag:function(a,c,d){var f=this;a=b.trim(a);this.options.preprocessTag&&(a=this.options.preprocessTag(a));
if(""===a)return!1;if(!this.options.allowDuplicates&&!this._isNew(a))return a=this._findTagByLabel(a),!1!==this._trigger("onTagExists",null,{existingTag:a,duringInitialization:d})&&this._effectExists("highlight")&&a.effect("highlight"),!1;if(this.options.tagLimit&&this._tags().length>=this.options.tagLimit)return this._trigger("onTagLimitExceeded",null,{duringInitialization:d}),!1;var g=b(this.options.onTagClicked?'<a class="tagit-label"></a>':'<span class="tagit-label"></span>').text(a),e=b("<li></li>").addClass("tagit-choice ui-widget-content ui-state-default ui-corner-all").addClass(c).append(g);
this.options.readOnly?e.addClass("tagit-choice-read-only"):(e.addClass("tagit-choice-editable"),c=b("<span></span>").addClass("ui-icon ui-icon-close"),c=b('<a><span class="text-icon">\u00d7</span></a>').addClass("tagit-close").append(c).click(function(a){f.removeTag(e)}),e.append(c));this.options.singleField||(g=g.html(),e.append('<input type="hidden" value="'+g+'" name="'+this.options.fieldName+'" class="tagit-hidden-field" />'));!1!==this._trigger("beforeTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),
duringInitialization:d})&&(this.options.singleField&&(g=this.assignedTags(),g.push(a),this._updateSingleTagsField(g)),this._trigger("onTagAdded",null,e),this.tagInput.val(""),this.tagInput.parent().before(e),this._trigger("afterTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),duringInitialization:d}),this.options.showAutocompleteOnFocus&&!d&&setTimeout(function(){f._showAutocomplete()},0))},removeTag:function(a,c){c="undefined"===typeof c?this.options.animate:c;a=b(a);this._trigger("onTagRemoved",
null,a);if(!1!==this._trigger("beforeTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})){if(this.options.singleField){var d=this.assignedTags(),f=this.tagLabel(a),d=b.grep(d,function(a){return a!=f});this._updateSingleTagsField(d)}if(c){a.addClass("removed");var d=this._effectExists("blind")?["blind",{direction:"horizontal"},"fast"]:["fast"],g=this;d.push(function(){a.remove();g._trigger("afterTagRemoved",null,{tag:a,tagLabel:g.tagLabel(a)})});a.fadeOut("fast").hide.apply(a,d).dequeue()}else a.remove(),
this._trigger("afterTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})}},removeTagByLabel:function(a,b){var d=this._findTagByLabel(a);if(!d)throw"No such tag exists with the name '"+a+"'";this.removeTag(d,b)},removeAll:function(){var a=this;this._tags().each(function(b,d){a.removeTag(d,!1)})}})})(jQuery);

View file

@ -0,0 +1,32 @@
$(function() {
$('input[name="username"]').keydown(function(event) {
if (event.which == 13) {
$('input[name="password"]').focus().select();
return false;
}
return true;
});
$('form').submit(function() {
if (! $('input[name="username"]').val()) {
with ($('input[name="username"]').get(0)) {
select();
focus();
}
return false;
}
if (! $('input[name="password"]').val()) {
with ($('input[name="password"]').get(0)) {
select();
focus();
}
return false;
}
return true;
});
$('input[name="username"]').focus();
});

View file

@ -0,0 +1,29 @@
/************************************************************
*
* tailbone.appsettings.js
*
* Logic for App Settings page.
*
************************************************************/
function show_group(group) {
if (group == "(All)") {
$('.panel').show();
} else {
$('.panel').hide();
$('.panel[data-groupname="' + group + '"]').show();
}
}
$(function() {
$('#settings-group').on('selectmenuchange', function(event, ui) {
show_group(ui.item.value);
});
show_group($('#settings-group').val());
});

View file

@ -0,0 +1,41 @@
/************************************************************
*
* tailbone.batch.js
*
* Common logic for view/edit batch pages
*
************************************************************/
$(function() {
$('#execute-batch').click(function() {
if (has_execution_options) {
$('#execution-options-dialog').dialog({
title: "Execution Options",
width: 600,
modal: true,
buttons: [
{
text: "Execute",
click: function(event) {
dialog_button(event).button('option', 'label', "Executing, please wait...").button('disable');
$('form[name="batch-execution"]').submit();
}
},
{
text: "Cancel",
click: function() {
$(this).dialog('close');
}
}
]
});
} else {
$(this).button('option', 'label', "Executing, please wait...").button('disable');
$('form[name="batch-execution"]').submit();
}
});
});

View file

@ -11,7 +11,7 @@ const TailboneDatepicker = {
'icon="calendar-alt"',
':date-formatter="formatDate"',
':date-parser="parseDate"',
':value="buefyValue"',
':value="value ? parseDate(value) : null"',
'@input="dateChanged"',
':disabled="disabled"',
'ref="trueDatePicker"',
@ -26,18 +26,6 @@ const TailboneDatepicker = {
disabled: Boolean,
},
data() {
return {
buefyValue: this.parseDate(this.value),
}
},
watch: {
value(to, from) {
this.buefyValue = this.parseDate(to)
},
},
methods: {
formatDate(date) {
@ -55,12 +43,9 @@ const TailboneDatepicker = {
},
parseDate(date) {
if (typeof(date) == 'string') {
// note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
var parts = date.split('-')
return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
}
return date
// note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
var parts = date.split('-')
return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
},
dateChanged(date) {

View file

@ -0,0 +1,156 @@
const GridFilterNumericValue = {
template: '#grid-filter-numeric-value-template',
props: {
value: String,
wantsRange: Boolean,
},
data() {
return {
startValue: null,
endValue: null,
}
},
mounted() {
if (this.wantsRange) {
if (this.value.includes('|')) {
let values = this.value.split('|')
if (values.length == 2) {
this.startValue = values[0]
this.endValue = values[1]
} else {
this.startValue = this.value
}
} else {
this.startValue = this.value
}
} else {
this.startValue = this.value
}
},
methods: {
focus() {
this.$refs.startValue.focus()
},
startValueChanged(value) {
if (this.wantsRange) {
value += '|' + this.endValue
}
this.$emit('input', value)
},
endValueChanged(value) {
value = this.startValue + '|' + value
this.$emit('input', value)
},
},
}
Vue.component('grid-filter-numeric-value', GridFilterNumericValue)
const GridFilterDateValue = {
template: '#grid-filter-date-value-template',
props: {
value: String,
dateRange: Boolean,
},
data() {
return {
startDate: null,
endDate: null,
}
},
mounted() {
if (this.dateRange) {
if (this.value.includes('|')) {
let values = this.value.split('|')
if (values.length == 2) {
this.startDate = values[0]
this.endDate = values[1]
} else {
this.startDate = this.value
}
} else {
this.startDate = this.value
}
} else {
this.startDate = this.value
}
},
methods: {
focus() {
this.$refs.startDate.focus()
},
startDateChanged(value) {
if (this.dateRange) {
value += '|' + this.endDate
}
this.$emit('input', value)
},
endDateChanged(value) {
value = this.startDate + '|' + value
this.$emit('input', value)
},
},
}
Vue.component('grid-filter-date-value', GridFilterDateValue)
const GridFilter = {
template: '#grid-filter-template',
props: {
filter: Object
},
methods: {
changeVerb() {
// set focus to value input, "as quickly as we can"
this.$nextTick(function() {
this.focusValue()
})
},
valuedVerb() {
/* this returns true if the filter's current verb should expose value input(s) */
// if filter has no "valueless" verbs, then all verbs should expose value inputs
if (!this.filter.valueless_verbs) {
return true
}
// if filter *does* have valueless verbs, check if "current" verb is valueless
if (this.filter.valueless_verbs.includes(this.filter.verb)) {
return false
}
// current verb is *not* valueless
return true
},
multiValuedVerb() {
/* this returns true if the filter's current verb should expose a multi-value input */
// if filter has no "multi-value" verbs then we safely assume false
if (!this.filter.multiple_value_verbs) {
return false
}
// if filter *does* have multi-value verbs, see if "current" is one
if (this.filter.multiple_value_verbs.includes(this.filter.verb)) {
return true
}
// current verb is not multi-value
return false
},
focusValue: function() {
this.$refs.valueInput.focus()
// this.$refs.valueInput.select()
}
}
}
Vue.component('grid-filter', GridFilter)

View file

@ -9,55 +9,15 @@ const TailboneTimepicker = {
'placeholder="Click to select ..."',
'icon-pack="fas"',
'icon="clock"',
':value="value ? parseTime(value) : null"',
'hour-format="12"',
'@input="timeChanged"',
':time-formatter="formatTime"',
'>',
'</b-timepicker>'
].join(' '),
props: {
name: String,
id: String,
value: String,
},
methods: {
formatTime(time) {
if (time === null) {
return null
}
let h = time.getHours()
let m = time.getMinutes()
let s = time.getSeconds()
h = h < 10 ? '0' + h : h
m = m < 10 ? '0' + m : m
s = s < 10 ? '0' + s : s
return h + ':' + m + ':' + s
},
parseTime(time) {
if (time.getHours) {
return time
}
let found = time.match(/^(\d\d):(\d\d):\d\d$/)
if (found) {
return new Date(null, null, null,
parseInt(found[1]), parseInt(found[2]))
}
},
timeChanged(time) {
this.$emit('input', time)
},
},
id: String
}
}
Vue.component('tailbone-timepicker', TailboneTimepicker)

View file

@ -0,0 +1,193 @@
/************************************************************
*
* tailbone.edit-shifts.js
*
* Common logic for editing time sheet / schedule data.
*
************************************************************/
var editing_day = null;
var new_shift_id = 1;
function add_shift(focus, uuid, start_time, end_time) {
var shift = $('#snippets .shift').clone();
if (! uuid) {
uuid = 'new-' + (new_shift_id++).toString();
}
shift.attr('data-uuid', uuid);
shift.children('input').each(function() {
var name = $(this).attr('name') + '-' + uuid;
$(this).attr('name', name);
$(this).attr('id', name);
});
shift.children('input[name|="edit_start_time"]').val(start_time || '');
shift.children('input[name|="edit_end_time"]').val(end_time || '');
$('#day-editor .shifts').append(shift);
shift.children('input').timepicker({showPeriod: true});
if (focus) {
shift.children('input:first').focus();
}
}
function calc_minutes(start_time, end_time) {
var start = parseTime(start_time);
start = new Date(2000, 0, 1, start.hh, start.mm);
var end = parseTime(end_time);
end = new Date(2000, 0, 1, end.hh, end.mm);
return Math.floor((end - start) / 1000 / 60);
}
function format_minutes(minutes) {
var hours = Math.floor(minutes / 60);
if (hours) {
minutes -= hours * 60;
}
return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString();
}
// stolen from http://stackoverflow.com/a/1788084
function parseTime(s) {
var part = s.match(/(\d+):(\d+)(?: )?(am|pm)?/i);
var hh = parseInt(part[1], 10);
var mm = parseInt(part[2], 10);
var ap = part[3] ? part[3].toUpperCase() : null;
if (ap == 'AM') {
if (hh == 12) {
hh = 0;
}
} else if (ap == 'PM') {
if (hh != 12) {
hh += 12;
}
}
return { hh: hh, mm: mm };
}
function time_input(shift, type) {
var input = shift.children('input[name|="' + type + '_time"]');
if (! input.length) {
input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />');
shift.append(input);
}
return input;
}
function update_row_hours(row) {
var minutes = 0;
row.find('.day .shift:not(.deleted)').each(function() {
var time_range = $.trim($(this).children('span').text()).split(' - ');
minutes += calc_minutes(time_range[0], time_range[1]);
});
row.children('.total').text(minutes ? format_minutes(minutes) : '0');
}
$(function() {
$('.timesheet').on('click', '.day', function() {
editing_day = $(this);
var editor = $('#day-editor');
var employee = editing_day.siblings('.employee').text();
var date = weekdays[editing_day.get(0).cellIndex - 1];
var shifts = editor.children('.shifts');
shifts.empty();
editing_day.children('.shift:not(.deleted)').each(function() {
var uuid = $(this).data('uuid');
var time_range = $.trim($(this).children('span').text()).split(' - ');
add_shift(false, uuid, time_range[0], time_range[1]);
});
if (! shifts.children('.shift').length) {
add_shift();
}
editor.dialog({
modal: true,
title: employee + ' - ' + date,
position: {my: 'center', at: 'center', of: editing_day},
width: 'auto',
autoResize: true,
buttons: [
{
text: "Update",
click: function() {
// TODO: is this hacky? invoking timepicker to format the time values
// in all cases, to avoid "invalid format" from user input
editor.find('.shifts .shift').each(function() {
var start_time = $(this).children('input[name|="edit_start_time"]');
var end_time = $(this).children('input[name|="edit_end_time"]');
$.timepicker._setTime(start_time.data('timepicker'), start_time.val());
$.timepicker._setTime(end_time.data('timepicker'), end_time.val());
});
// create / update shifts in time table, as needed
editor.find('.shifts .shift').each(function() {
var uuid = $(this).data('uuid');
var start_time = $(this).children('input[name|="edit_start_time"]').val();
var end_time = $(this).children('input[name|="edit_end_time"]').val();
var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]');
if (! shift.length) {
shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>');
shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="'
+ editing_day.parents('tr:first').data('employee-uuid') + '" />'));
editing_day.append(shift);
}
shift.children('span').text(start_time + ' - ' + end_time);
time_input(shift, 'start').val(date + ' ' + start_time);
time_input(shift, 'end').val(date + ' ' + end_time);
});
// remove shifts from time table, as needed
editing_day.children('.shift').each(function() {
var uuid = $(this).data('uuid');
if (! editor.find('.shifts .shift[data-uuid="' + uuid + '"]').length) {
if (uuid.match(/^new-/)) {
$(this).remove();
} else {
$(this).addClass('deleted');
$(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />'));
}
}
});
// mark day as modified, close dialog
editing_day.addClass('modified');
$('.save-changes').button('enable');
$('.undo-changes').button('enable');
update_row_hours(editing_day.parents('tr:first'));
editor.dialog('close');
data_modified = true;
okay_to_leave = false;
}
},
{
text: "Cancel",
click: function() {
editor.dialog('close');
}
}
]
});
});
$('#day-editor #add-shift').click(function() {
add_shift(true);
});
$('#day-editor').on('click', '.shifts button', function() {
$(this).parents('.shift:first').remove();
});
$('.save-changes').click(function() {
$(this).button('disable').button('option', 'label', "Saving Changes...");
okay_to_leave = true;
$('#timetable-form').submit();
});
$('.undo-changes').click(function() {
$(this).button('disable').button('option', 'label', "Refreshing...");
okay_to_leave = true;
location.href = location.href;
});
});

View file

@ -1,55 +1,58 @@
let FeedbackForm = {
props: ['action', 'message'],
template: '#feedback-template',
mixins: [FormPosterMixin],
methods: {
$(function() {
pleaseReplyChanged(value) {
this.$nextTick(() => {
this.$refs.userEmail.focus()
})
},
$('#feedback').click(function() {
var dialog = $('#feedback-dialog');
var form = dialog.find('form');
var textarea = form.find('textarea');
dialog.find('.referrer .field').html(location.href);
textarea.val('');
dialog.dialog({
title: "User Feedback",
width: 600,
modal: true,
buttons: [
{
text: "Send",
click: function(event) {
showFeedback() {
this.referrer = location.href
this.showDialog = true
this.$nextTick(function() {
this.$refs.textarea.focus()
})
},
var msg = $.trim(textarea.val());
if (! msg) {
alert("Please enter a message.");
textarea.select();
textarea.focus();
return;
}
sendFeedback() {
disable_button(dialog_button(event));
let params = {
referrer: this.referrer,
user: this.userUUID,
user_name: this.userName,
please_reply_to: this.pleaseReply ? this.userEmail : null,
message: this.message.trim(),
}
var data = {
_csrf: form.find('input[name="_csrf"]').val(),
referrer: location.href,
user: form.find('input[name="user"]').val(),
user_name: form.find('input[name="user_name"]').val(),
message: msg
};
this.submitForm(this.action, params, response => {
$.ajax(form.attr('action'), {
method: 'POST',
data: data,
success: function(data) {
dialog.dialog('close');
alert("Message successfully sent.\n\nThank you for your feedback.");
}
});
this.$buefy.toast.open({
message: "Message sent! Thank you for your feedback.",
type: 'is-info',
duration: 4000, // 4 seconds
})
this.showDialog = false
// clear out message, in case they need to send another
this.message = ""
})
},
}
}
let FeedbackFormData = {
referrer: null,
userUUID: null,
userName: null,
pleaseReply: false,
userEmail: null,
showDialog: false,
}
}
},
{
text: "Cancel",
click: function() {
dialog.dialog('close');
}
}
]
});
});
});

View file

@ -0,0 +1,386 @@
/************************************************************
*
* tailbone.js
*
************************************************************/
/*
* Initialize the disabled filters array. This is populated from within the
* /grids/search.mako template.
*/
var filters_to_disable = [];
/*
* Disables options within the "add filter" dropdown which correspond to those
* filters already being displayed. Called from /grids/search.mako template.
*/
function disable_filter_options() {
while (filters_to_disable.length) {
var filter = filters_to_disable.shift();
var option = $('#add-filter option[value="' + filter + '"]');
option.attr('disabled', 'disabled');
}
}
/*
* Convenience function to disable a UI button.
*/
function disable_button(button, label) {
$(button).button('disable');
if (label === undefined) {
label = $(button).data('working-label') || "Working, please wait...";
}
if (label) {
if (label.slice(-3) != '...') {
label += '...';
}
$(button).button('option', 'label', label);
}
}
function disable_submit_button(form, label) {
// for some reason chrome requires us to do things this way...
// https://stackoverflow.com/questions/16867080/onclick-javascript-stops-form-submit-in-chrome
// https://stackoverflow.com/questions/5691054/disable-submit-button-on-form-submit
var submit = $(form).find('input[type="submit"]');
if (! submit.length) {
submit = $(form).find('button[type="submit"]');
}
if (submit.length) {
disable_button(submit, label);
}
}
/*
* Load next / previous page of results to grid. This function is called on
* the click event from the pager links, via inline script code.
*/
function grid_navigate_page(link, url) {
var wrapper = $(link).parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
wrapper.mask("Loading...");
$.get(url, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
}
/*
* Fetch the UUID value associated with a table row.
*/
function get_uuid(obj) {
obj = $(obj);
if (obj.attr('uuid')) {
return obj.attr('uuid');
}
var tr = obj.parents('tr:first');
if (tr.attr('uuid')) {
return tr.attr('uuid');
}
return undefined;
}
/*
* Return a jQuery object containing a button from a dialog. This is a
* convenience function to help with browser differences. It is assumed
* that it is being called from within the relevant button click handler.
* @param {event} event - Click event object.
*/
function dialog_button(event) {
var button = $(event.target);
// TODO: not sure why this workaround is needed for Chrome..?
if (! button.hasClass('ui-button')) {
button = button.parents('.ui-button:first');
}
return button;
}
/**
* Scroll screen as needed to ensure all options are visible, for the given
* select menu widget.
*/
function show_all_options(select) {
if (! select.is(':visible')) {
/*
* Note that the following code was largely stolen from
* http://brianseekford.com/2013/06/03/how-to-scroll-a-container-or-element-into-view-using-jquery-javascript-in-your-html/
*/
var docViewTop = $(window).scrollTop();
var docViewBottom = docViewTop + $(window).height();
var widget = select.selectmenu('menuWidget');
var elemTop = widget.offset().top;
var elemBottom = elemTop + widget.height();
var isScrolled = ((elemBottom <= docViewBottom) && (elemTop >= docViewTop));
if (!isScrolled) {
if (widget.height() > $(window).height()) { //then just bring to top of the container
$(window).scrollTop(elemTop)
} else { //try and and bring bottom of container to bottom of screen
$(window).scrollTop(elemTop - ($(window).height() - widget.height()));
}
}
}
}
/*
* reference to existing timeout warning dialog, if any
*/
var session_timeout_warning = null;
/**
* Warn user of impending session timeout.
*/
function timeout_warning() {
if (! session_timeout_warning) {
session_timeout_warning = $('<div id="session-timeout-warning">' +
'You will be logged out in <span class="seconds"></span> ' +
'seconds...</div>');
}
session_timeout_warning.find('.seconds').text('60');
session_timeout_warning.dialog({
title: "Session Timeout Warning",
modal: true,
buttons: {
"Stay Logged In": function() {
session_timeout_warning.dialog('close');
$.get(noop_url, set_timeout_warning_timer);
},
"Logout Now": function() {
location.href = logout_url;
}
}
});
window.setTimeout(timeout_warning_update, 1000);
}
/**
* Decrement the 'seconds' counter for the current timeout warning
*/
function timeout_warning_update() {
if (session_timeout_warning.is(':visible')) {
var span = session_timeout_warning.find('.seconds');
var seconds = parseInt(span.text()) - 1;
if (seconds) {
span.text(seconds.toString());
window.setTimeout(timeout_warning_update, 1000);
} else {
location.href = logout_url;
}
}
}
/**
* Warn user of impending session timeout.
*/
function set_timeout_warning_timer() {
// timout dialog says we're 60 seconds away, but we actually trigger when
// 70 seconds away from supposed timeout, in case of timer drift?
window.setTimeout(timeout_warning, session_timeout * 1000 - 70000);
}
/*
* set initial timer for timeout warning, if applicable
*/
if (session_timeout) {
set_timeout_warning_timer();
}
$(function() {
/*
* enhance buttons
*/
$('button, a.button').button();
$('input[type=submit]').button();
$('input[type=reset]').button();
$('a.button.autodisable').click(function() {
disable_button(this);
});
$('form.autodisable').submit(function() {
disable_submit_button(this);
});
// quickie button
$('#submit-quickie').button('option', 'icons', {primary: 'ui-icon-zoomin'});
/*
* enhance dropdowns
*/
$('select[auto-enhance="true"]').selectmenu();
$('select[auto-enhance="true"]').on('selectmenuopen', function(event, ui) {
show_all_options($(this));
});
/* Also automatically disable any buttons marked for that. */
$('a.button[disabled=disabled]').button('option', 'disabled', true);
/*
* Apply timepicker behavior to text inputs which are marked for it.
*/
$('input[type=text].timepicker').timepicker({
showPeriod: true
});
/*
* When filter labels are clicked, (un)check the associated checkbox.
*/
$('body').on('click', '.grid-wrapper .filter label', function() {
var checkbox = $(this).prev('input[type="checkbox"]');
if (checkbox.prop('checked')) {
checkbox.prop('checked', false);
return false;
}
checkbox.prop('checked', true);
});
/*
* When a new filter is selected in the "add filter" dropdown, show it in
* the UI. This selects the filter's checkbox and puts focus to its input
* element. If all available filters have been displayed, the "add filter"
* dropdown will be hidden.
*/
$('body').on('change', '#add-filter', function() {
var select = $(this);
var filters = select.parents('div.filters:first');
var filter = filters.find('#filter-' + select.val());
var checkbox = filter.find('input[type="checkbox"]:first');
var input = filter.find(':last-child');
checkbox.prop('checked', true);
filter.show();
input.select();
input.focus();
filters.find('input[type="submit"]').show();
filters.find('button[type="reset"]').show();
select.find('option:selected').attr('disabled', true);
select.val('add a filter');
if (select.find('option:enabled').length == 1) {
select.hide();
}
});
/*
* When user clicks the grid filters search button, perform the search in
* the background and reload the grid in-place.
*/
$('body').on('submit', '.filters form', function() {
var form = $(this);
var wrapper = form.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
var data = form.serializeArray();
data.push({name: 'partial', value: true});
wrapper.mask("Loading...");
$.get(grid.attr('url'), data, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
return false;
});
/*
* When user clicks the grid filters reset button, manually clear all
* filter input elements, and submit a new search.
*/
$('body').on('click', '.filters form button[type="reset"]', function() {
var form = $(this).parents('form');
form.find('div.filter').each(function() {
$(this).find('div.value input').val('');
});
form.submit();
return false;
});
$('body').on('click', '.grid thead th.sortable a', function() {
var th = $(this).parent();
var wrapper = th.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
var data = {
sort: th.attr('field'),
dir: (th.hasClass('sorted') && th.hasClass('asc')) ? 'desc' : 'asc',
page: 1,
partial: true
};
wrapper.mask("Loading...");
$.get(grid.attr('url'), data, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
return false;
});
$('body').on('mouseenter', '.grid.hoverable tbody tr', function() {
$(this).addClass('hovering');
});
$('body').on('mouseleave', '.grid.hoverable tbody tr', function() {
$(this).removeClass('hovering');
});
$('body').on('click', '.grid tbody td.view', function() {
var url = $(this).attr('url');
if (url) {
location.href = url;
}
});
$('body').on('click', '.grid tbody td.edit', function() {
var url = $(this).attr('url');
if (url) {
location.href = url;
}
});
$('body').on('click', '.grid tbody td.delete', function() {
var url = $(this).attr('url');
if (url) {
if (confirm("Do you really wish to delete this object?")) {
location.href = url;
}
}
});
// $('div.grid-wrapper').on('change', 'div.grid div.pager select#grid-page-count', function() {
$('body').on('change', '.grid .pager #grid-page-count', function() {
var select = $(this);
var wrapper = select.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
var data = {
per_page: select.val(),
partial: true
};
wrapper.mask("Loading...");
$.get(grid.attr('url'), data, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
});
$('body').on('click', 'div.dialog button.close', function() {
var dialog = $(this).parents('div.dialog:first');
dialog.dialog('close');
});
});

View file

@ -0,0 +1,267 @@
/************************************************************
*
* tailbone.timesheet.edit.js
*
* Common logic for editing time sheet / schedule data.
*
************************************************************/
var editing_day = null;
var new_shift_id = 1;
var show_timepicker = true;
/*
* Add a new shift entry to the editor dialog.
* @param {boolean} focus - Whether to set focus to the start_time input
* element after adding the shift.
* @param {string} uuid - UUID value for the shift, if applicable.
* @param {string} start_time - Value for start_time input element.
* @param {string} end_time - Value for end_time input element.
*/
function add_shift(focus, uuid, start_time, end_time) {
var shift = $('#snippets .shift').clone();
if (! uuid) {
uuid = 'new-' + (new_shift_id++).toString();
}
shift.attr('data-uuid', uuid);
shift.children('input').each(function() {
var name = $(this).attr('name') + '-' + uuid;
$(this).attr('name', name);
$(this).attr('id', name);
});
shift.children('input[name|="edit_start_time"]').val(start_time);
shift.children('input[name|="edit_end_time"]').val(end_time);
$('#day-editor .shifts').append(shift);
// maybe trick timepicker into never showing itself
var args = {showPeriod: true};
if (! show_timepicker) {
args.showOn = 'button';
args.button = '#nevershow';
}
shift.children('input').timepicker(args);
if (focus) {
shift.children('input:first').focus();
}
}
/**
* Calculate the number of minutes between given the times.
* @param {string} start_time - Value from start_time input element.
* @param {string} end_time - Value from end_time input element.
*/
function calc_minutes(start_time, end_time) {
var start = parseTime(start_time);
var end = parseTime(end_time);
if (start && end) {
start = new Date(2000, 0, 1, start.hh, start.mm);
end = new Date(2000, 0, 1, end.hh, end.mm);
return Math.floor((end - start) / 1000 / 60);
}
}
/**
* Converts a number of minutes into string of HH:MM format.
* @param {number} minutes - Number of minutes to be converted.
*/
function format_minutes(minutes) {
var hours = Math.floor(minutes / 60);
if (hours) {
minutes -= hours * 60;
}
return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString();
}
/**
* NOTE: most of this logic was stolen from http://stackoverflow.com/a/1788084
*
* Parse a time string and convert to simple object with hh and mm keys.
* @param {string} time - Time value in 'HH:MM PP' format, or close enough.
*/
function parseTime(time) {
if (time) {
var part = time.match(/(\d+):(\d+)(?: )?(am|pm)?/i);
if (part) {
var hh = parseInt(part[1], 10);
var mm = parseInt(part[2], 10);
var ap = part[3] ? part[3].toUpperCase() : null;
if (ap == 'AM') {
if (hh == 12) {
hh = 0;
}
} else if (ap == 'PM') {
if (hh != 12) {
hh += 12;
}
}
return { hh: hh, mm: mm };
}
}
}
/**
* Return a jQuery object containing the hidden start or end time input element
* for the shift (i.e. within the *main* timesheet form). This will create the
* input if necessary.
* @param {jQuery} shift - A jQuery object for the shift itself.
* @param {string} type - Should be 'start' or 'end' only.
*/
function time_input(shift, type) {
var input = shift.children('input[name|="' + type + '_time"]');
if (! input.length) {
input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />');
shift.append(input);
}
return input;
}
/**
* Update the weekly hour total for a given row (employee).
* @param {jQuery} row - A jQuery object for the row to be updated.
*/
function update_row_hours(row) {
var minutes = 0;
row.find('.day .shift:not(.deleted)').each(function() {
var time_range = $.trim($(this).children('span').text()).split(' - ');
minutes += calc_minutes(time_range[0], time_range[1]);
});
row.children('.total').text(minutes ? format_minutes(minutes) : '0');
}
/**
* Clean up user input within the editor dialog, e.g. '8:30am' => '08:30 AM'.
* This also should ensure invalid input will become empty string.
*/
function cleanup_editor_input() {
// TODO: is this hacky? invoking timepicker to format the time values
// in all cases, to avoid "invalid format" from user input
var backward = false;
$('#day-editor .shifts .shift').each(function() {
var start_time = $(this).children('input[name|="edit_start_time"]');
var end_time = $(this).children('input[name|="edit_end_time"]');
$.timepicker._setTime(start_time.data('timepicker'), start_time.val() || '??');
$.timepicker._setTime(end_time.data('timepicker'), end_time.val() || '??');
var t_start = parseTime(start_time.val());
var t_end = parseTime(end_time.val());
if (t_start && t_end) {
if ((t_start.hh > t_end.hh) || ((t_start.hh == t_end.hh) && (t_start.mm > t_end.mm))) {
alert("Start time falls *after* end time! Please fix...");
start_time.focus().select();
backward = true;
return false;
}
}
});
return !backward;
}
/**
* Update the main timesheet table based on editor dialog input. This updates
* both the displayed timesheet, as well as any hidden input elements on the
* main form.
*/
function update_timetable() {
var date = weekdays[editing_day.get(0).cellIndex - 1];
// add or update
$('#day-editor .shifts .shift').each(function() {
var uuid = $(this).data('uuid');
var start_time = $(this).children('input[name|="edit_start_time"]').val();
var end_time = $(this).children('input[name|="edit_end_time"]').val();
var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]');
if (! shift.length) {
if (! (start_time || end_time)) {
return;
}
shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>');
shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="'
+ editing_day.parents('tr:first').data('employee-uuid') + '" />'));
editing_day.append(shift);
}
shift.children('span').text((start_time || '??') + ' - ' + (end_time || '??'));
start_time = start_time ? (date + ' ' + start_time) : '';
end_time = end_time ? (date + ' ' + end_time) : '';
time_input(shift, 'start').val(start_time);
time_input(shift, 'end').val(end_time);
});
// remove / mark for deletion
editing_day.children('.shift').each(function() {
var uuid = $(this).data('uuid');
if (! $('#day-editor .shifts .shift[data-uuid="' + uuid + '"]').length) {
if (uuid.match(/^new-/)) {
$(this).remove();
} else {
$(this).addClass('deleted');
$(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />'));
}
}
});
}
/**
* Perform full "save" action for time sheet form, direct from day editor dialog.
*/
function save_dialog() {
if (! cleanup_editor_input()) {
return false;
}
var save = $('#day-editor').parents('.ui-dialog').find('.ui-dialog-buttonpane button:first');
save.button('disable').button('option', 'label', "Saving...");
update_timetable();
$('#timetable-form').submit();
return true;
}
/*
* on document load...
*/
$(function() {
/*
* Within editor dialog, clicking Add Shift button will create a new/empty
* shift and set focus to its start_time input.
*/
$('#day-editor #add-shift').click(function() {
add_shift(true);
});
/*
* Within editor dialog, clicking a shift's "trash can" button will remove
* the shift.
*/
$('#day-editor').on('click', '.shifts button', function() {
$(this).parents('.shift:first').remove();
});
/*
* Within editor dialog, Enter press within time field "might" trigger
* save. Note that this is only done for timesheet editing, not schedule.
*/
$('#day-editor').on('keydown', '.shifts input[type="text"]', function(event) {
if (!show_timepicker) { // TODO: this implies too much, should be cleaner
if (event.which == 13) {
save_dialog();
return false;
}
}
});
});

View file

@ -0,0 +1,14 @@
/******************************
* tweaks for root user
******************************/
.navbar .navbar-end .navbar-link.root-user,
.navbar .navbar-end .navbar-link.root-user:hover,
.navbar .navbar-end .navbar-link.root-user.is_active,
.navbar .navbar-end .navbar-item.root-user,
.navbar .navbar-end .navbar-item.root-user:hover,
.navbar .navbar-end .navbar-item.root-user.is_active {
background-color: red;
font-weight: bold;
}

View file

@ -0,0 +1,22 @@
/******************************
* Grid Filters
******************************/
.filters .filter {
margin-bottom: 0.5rem;
}
.filters .filter-fieldname .field,
.filters .filter-fieldname .field label {
width: 100%;
}
.filters .filter-fieldname .field label {
justify-content: left;
}
.filters .filter-verb .select,
.filters .filter-verb .select select {
width: 100%;
}

View file

@ -0,0 +1,61 @@
/******************************
* forms
******************************/
/* note that this should only apply to "normal" primary forms */
/* TODO: replace this with bulma equivalent */
.form {
padding-left: 5em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-label .label {
text-align: left;
white-space: nowrap;
width: 18em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-body {
min-width: 30em;
}
/* note that this should only apply to "normal" primary forms */
.form-wrapper .form .field.is-horizontal .field-body .select,
.form-wrapper .form .field.is-horizontal .field-body .select select {
width: 100%;
}
/******************************
* field-wrappers
******************************/
/* TODO: replace this with bulma equivalent */
.field-wrapper {
clear: both;
min-height: 30px;
overflow: auto;
margin: 15px;
}
/* TODO: replace this with bulma equivalent */
.field-wrapper .field-row {
display: table-row;
}
/* TODO: replace this with bulma equivalent */
.field-wrapper label {
display: table-cell;
vertical-align: top;
width: 18em;
font-weight: bold;
padding-top: 2px;
white-space: nowrap;
}
/* TODO: replace this with bulma equivalent */
.field-wrapper .field {
display: table-cell;
line-height: 25px;
}

View file

@ -0,0 +1,15 @@
/********************************************************************************
* grids.css
*
* Style tweaks for the Buefy grids.
********************************************************************************/
/******************************
* actions column
******************************/
a.grid-action {
white-space: nowrap;
}

View file

@ -2,7 +2,7 @@
/********************************************************************************
* grids.rowstatus.css
*
* Add "row status" styles for grid tables.
* Add "row status" styles for Buefy grid tables.
********************************************************************************/
/**************************************************

View file

@ -0,0 +1,133 @@
/******************************
* main layout
******************************/
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.content-wrapper {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
}
/******************************
* 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 {
/* TODO: not sure what this 60px was supposed to do? but it broke the */
/* styles for the feedback dialog, so disabled it is.
/* height: 60px; */
/* line-height: 60px; */
padding-left: 0.5em;
padding-right: 0.5em;
}
header .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;
}
header .level .theme-picker {
display: inline-flex;
}
#content-title {
padding: 0.3rem;
}
#content-title h1 {
font-size: 2rem;
margin-left: 1rem;
}
/******************************
* content
******************************/
#page-body {
padding: 0.4em;
}
/******************************
* context menu
******************************/
#context-menu {
margin-bottom: 1em;
margin-left: 1em;
text-align: right;
white-space: nowrap;
}
/******************************
* "object helper" panel
******************************/
.object-helpers a {
white-space: nowrap;
}
.object-helper {
border: 1px solid black;
margin: 1em;
padding: 1em;
width: 20em;
}
.object-helper-content {
margin-top: 1em;
}
/******************************
* fix datepicker within modals
* TODO: someday this may not be necessary? cf.
* https://github.com/buefy/buefy/issues/292#issuecomment-347365637
******************************/
.modal .animation-content .modal-card {
overflow: visible !important;
}
.modal-card-body {
overflow: visible !important;
}
/******************************
* feedback
******************************/
.feedback-dialog .red {
color: red;
font-weight: bold;
}

View file

@ -0,0 +1,54 @@
let FeedbackForm = {
props: ['action', 'message'],
template: '#feedback-template',
mixins: [FormPosterMixin],
methods: {
pleaseReplyChanged(value) {
this.$nextTick(() => {
this.$refs.userEmail.focus()
})
},
showFeedback() {
this.showDialog = true
this.$nextTick(function() {
this.$refs.textarea.focus()
})
},
sendFeedback() {
let params = {
referrer: this.referrer,
user: this.userUUID,
user_name: this.userName,
please_reply_to: this.pleaseReply ? this.userEmail : null,
message: this.message.trim(),
}
this.submitForm(this.action, params, response => {
this.$buefy.toast.open({
message: "Message sent! Thank you for your feedback.",
type: 'is-info',
duration: 4000, // 4 seconds
})
this.showDialog = false
// clear out message, in case they need to send another
this.message = ""
})
},
}
}
let FeedbackFormData = {
referrer: null,
userUUID: null,
userName: null,
pleaseReply: false,
userEmail: null,
showDialog: false,
}

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,10 +24,11 @@
Event Subscribers
"""
from __future__ import unicode_literals, absolute_import
import six
import json
import datetime
import logging
import warnings
from collections import OrderedDict
import rattail
@ -36,169 +37,147 @@ import deform
from pyramid import threadlocal
from webhelpers2.html import tags
from wuttaweb import subscribers as base
import tailbone
from tailbone import helpers
from tailbone.db import Session
from tailbone.config import csrf_header_name, should_expose_websockets
from tailbone.util import get_available_themes, get_global_search_options
from tailbone.menus import make_simple_menus
from tailbone.util import should_use_buefy
log = logging.getLogger(__name__)
def new_request(event, session=None):
def new_request(event):
"""
Event hook called when processing a new request.
Identify the current user, and cache their current permissions. Also adds
the ``rattail_config`` attribute to the request.
This first invokes the upstream hooks:
A global Rattail ``config`` should already be present within the Pyramid
application registry's settings, which would normally be accessed via::
request.registry.settings['rattail_config']
* :func:`wuttaweb:wuttaweb.subscribers.new_request()`
* :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()`
This function merely "promotes" that config object so that it is more
directly accessible, a la::
It then adds more things to the request object; among them:
request.rattail_config
.. attribute:: request.rattail_config
Reference to the app :term:`config object`. Note that this
will be the same as :attr:`wuttaweb:request.wutta_config`.
.. method:: request.register_component(tagname, classname)
Function to register a Vue component for use with the app.
This can be called from wherever a component is defined, and
then in the base template all registered components will be
properly loaded.
.. note::
This of course assumes that a Rattail ``config`` object *has* in fact
already been placed in the application registry settings. If this is
not the case, this function will do nothing.
"""
request = event.request
rattail_config = request.registry.settings.get('rattail_config')
# TODO: why would this ever be null?
if rattail_config:
request.rattail_config = rattail_config
# invoke main upstream logic
# nb. this sets request.wutta_config
base.new_request(event)
def user(request):
user = None
uuid = request.authenticated_userid
if uuid:
model = request.rattail_config.get_model()
user = Session.query(model.User).get(uuid)
if user:
Session().set_continuum_user(user)
return user
config = request.wutta_config
app = config.get_app()
auth = app.get_auth_handler()
session = session or Session()
# compatibility
rattail_config = config
request.rattail_config = rattail_config
def user_getter(request, db_session=None):
user = base.default_user_getter(request, db_session=db_session)
if user:
# nb. we also assign continuum user to session
session = db_session or Session()
session.set_continuum_user(user)
return user
# invoke upstream hook to set user
base.new_request_set_user(event, user_getter=user_getter, db_session=session)
request.set_property(user, reify=True)
# assign client IP address to the session, for sake of versioning
if hasattr(request, 'client_addr'):
session.continuum_remote_addr = request.client_addr
Session().continuum_remote_addr = request.client_addr
# request.register_component()
def register_component(tagname, classname):
"""
Register a Vue 3 component, so the base template knows to
declare it for use within the app (page).
"""
if not hasattr(request, '_tailbone_registered_components'):
request._tailbone_registered_components = OrderedDict()
request.is_admin = bool(request.user) and request.user.is_admin()
request.is_root = request.is_admin and request.session.get('is_root', False)
if tagname in request._tailbone_registered_components:
log.warning("component with tagname '%s' already registered "
"with class '%s' but we are replacing that with "
"class '%s'",
tagname,
request._tailbone_registered_components[tagname],
classname)
request._tailbone_registered_components[tagname] = classname
request.register_component = register_component
if rattail_config:
app = rattail_config.get_app()
auth = app.get_auth_handler()
request.tailbone_cached_permissions = auth.get_permissions(
Session(), request.user)
def before_render(event):
"""
Adds goodies to the global template renderer context.
"""
# log.debug("before_render: %s", event)
# invoke upstream logic
base.before_render(event)
request = event.get('request') or threadlocal.get_current_request()
config = request.wutta_config
app = config.get_app()
rattail_config = request.rattail_config
renderer_globals = event
# overrides
renderer_globals['rattail_app'] = request.rattail_config.get_app()
renderer_globals['app_title'] = request.rattail_config.app_title()
renderer_globals['h'] = helpers
# misc.
renderer_globals['url'] = request.route_url
renderer_globals['rattail'] = rattail
renderer_globals['tailbone'] = tailbone
renderer_globals['model'] = request.rattail_config.get_model()
renderer_globals['enum'] = request.rattail_config.get_enum()
renderer_globals['six'] = six
renderer_globals['json'] = json
renderer_globals['datetime'] = datetime
renderer_globals['colander'] = colander
renderer_globals['deform'] = deform
renderer_globals['csrf_header_name'] = csrf_header_name(config)
# TODO: deprecate / remove these
renderer_globals['rattail_app'] = app
renderer_globals['app_title'] = app.get_title()
renderer_globals['app_version'] = app.get_version()
renderer_globals['rattail'] = rattail
renderer_globals['tailbone'] = tailbone
renderer_globals['model'] = app.model
renderer_globals['enum'] = app.enum
renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config)
# theme - we only want do this for classic web app, *not* API
# TODO: so, clearly we need a better way to distinguish the two
if 'tailbone.theme' in request.registry.settings:
renderer_globals['theme'] = request.registry.settings['tailbone.theme']
# note, this is just a global flag; user still needs permission to see picker
expose_picker = config.get_bool('tailbone.themes.expose_picker',
default=False)
expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker',
default=False)
renderer_globals['expose_theme_picker'] = expose_picker
if expose_picker:
# TODO: should remove 'falafel' option altogether
available = get_available_themes(config)
options = [tags.Option(theme, value=theme) for theme in available]
# tailbone's config extension provides a default theme selection,
# so the default we specify here *probably* should not matter
available = request.rattail_config.getlist('tailbone', 'themes',
default=['falafel'])
if 'default' not in available:
available.insert(0, 'default')
options = [tags.Option(theme) for theme in available]
renderer_globals['theme_picker_options'] = options
# heck while we're assuming the classic web app here...
# (we don't want this to happen for the API either!)
# TODO: just..awful *shrug*
# note that we assume "simple" menus nowadays
if request.rattail_config.getbool('tailbone', 'menus.simple', default=True):
renderer_globals['menus'] = make_simple_menus(request)
# TODO: ugh, same deal here
renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled',
default=False)
renderer_globals['messaging_enabled'] = request.rattail_config.getbool(
'tailbone', 'messaging.enabled', default=False)
# background color may be set per-request, by some apps
if hasattr(request, 'background_color') and request.background_color:
renderer_globals['background_color'] = request.background_color
else: # otherwise we use the one from config
renderer_globals['background_color'] = config.get('tailbone.background_color')
renderer_globals['background_color'] = request.rattail_config.get(
'tailbone', 'background_color')
# maybe set custom stylesheet
css = None
if request.user:
css = config.get(f'tailbone.{request.user.uuid}', 'user_css')
# buefy themes get some extra treatment
if should_use_buefy(request):
# declare vue.js and buefy versions to use. the default
# values here are "quite conservative" as of this writing,
# perhaps too much so, but at least they should work fine.
renderer_globals['vue_version'] = request.rattail_config.get(
'tailbone', 'vue_version') or '2.6.10'
renderer_globals['buefy_version'] = request.rattail_config.get(
'tailbone', 'buefy_version') or '0.8.13'
# maybe set custom stylesheet
css = None
if request.user:
css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid),
'buefy_css')
if not css:
css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css')
if css:
warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be"
f"changed to 'tailbone.{request.user.uuid}.user_css'",
DeprecationWarning)
renderer_globals['user_css'] = css
# add global search data for quick access
renderer_globals['global_search_data'] = get_global_search_options(request)
css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css')
renderer_globals['buefy_css'] = css
# here we globally declare widths for grid filter pseudo-columns
widths = config.get('tailbone.grids.filters.column_widths')
widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths')
if widths:
widths = widths.split(';')
if len(widths) < 2:
@ -209,7 +188,7 @@ def before_render(event):
renderer_globals['filter_verb_width'] = widths[1]
# declare global support for websockets, or lack thereof
renderer_globals['expose_websockets'] = should_expose_websockets(config)
renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config)
def add_inbox_count(event):
@ -223,9 +202,8 @@ def add_inbox_count(event):
request = event.get('request') or threadlocal.get_current_request()
if request.user:
renderer_globals = event
app = request.rattail_config.get_app()
model = app.model
enum = request.rattail_config.get_enum()
model = request.rattail_config.get_model()
renderer_globals['inbox_count'] = Session.query(model.Message)\
.outerjoin(model.MessageRecipient)\
.filter(model.MessageRecipient.recipient == Session.merge(request.user))\
@ -235,14 +213,51 @@ def add_inbox_count(event):
def context_found(event):
"""
Attach some more goodies to the request object:
Attach some goodies to the request object.
The following is attached to the request:
* ``get_session_timeout()`` function
* The currently logged-in user instance (if any), as ``user``.
* ``is_admin`` flag indicating whether user has the Administrator role.
* ``is_root`` flag indicating whether user is currently elevated to root.
* A shortcut method for permission checking, as ``has_perm()``.
* A shortcut method for fetching the referrer, as ``get_referrer()``.
"""
request = event.request
def has_perm(name):
if name in request.tailbone_cached_permissions:
return True
return request.is_root
request.has_perm = has_perm
def has_any_perm(*names):
for name in names:
if has_perm(name):
return True
return False
request.has_any_perm = has_any_perm
def get_referrer(default=None, **kwargs):
if request.params.get('referrer'):
return request.params['referrer']
if request.session.get('referrer'):
return request.session.pop('referrer')
referrer = request.referrer
if (not referrer or referrer == request.current_route_url()
or not referrer.startswith(request.host_url)):
if default:
referrer = default
else:
referrer = request.route_url('home')
return referrer
request.get_referrer = get_referrer
def get_session_timeout():
"""
Returns the timeout in effect for the current session

View file

@ -1,2 +0,0 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/appinfo/configure.mako" />

View file

@ -1,31 +0,0 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/appinfo/index.mako" />
<%def name="page_content()">
<div class="buttons">
<once-button type="is-primary"
tag="a" href="${url('tables')}"
icon-pack="fas"
icon-left="eye"
text="Tables">
</once-button>
<once-button type="is-primary"
tag="a" href="${url('model_views')}"
icon-pack="fas"
icon-left="eye"
text="Model Views">
</once-button>
<once-button type="is-primary"
tag="a" href="${url('configure_menus')}"
icon-pack="fas"
icon-left="cog"
text="Configure Menus">
</once-button>
</div>
${parent.page_content()}
</%def>

View file

@ -5,6 +5,34 @@
<%def name="content_title()"></%def>
<%def name="extra_javascript()">
${parent.extra_javascript()}
% if not use_buefy:
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.appsettings.js') + '?ver={}'.format(tailbone.__version__))}
% endif
</%def>
<%def name="extra_styles()">
${parent.extra_styles()}
% if not use_buefy:
<style type="text/css">
div.form {
float: none;
}
div.panel {
width: 85%;
}
.field-wrapper {
margin-bottom: 2em;
}
.panel .field-wrapper label {
font-family: monospace;
width: 50em;
}
</style>
% endif
</%def>
<%def name="context_menu_items()">
% if request.has_perm('settings.list'):
<li>${h.link_to("View Raw Settings", url('settings'))}</li>
@ -15,8 +43,8 @@
<app-settings :groups="groups" :showing-group="showingGroup"></app-settings>
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
<script type="text/x-template" id="app-settings-template">
<div class="form">
@ -150,18 +178,19 @@
</script>
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.groups = ${json.dumps(settings_data)|n}
ThisPageData.groups = ${json.dumps(buefy_data)|n}
ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
</script>
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
<script>
<%def name="make_this_page_component()">
${parent.make_this_page_component()}
<script type="text/javascript">
Vue.component('app-settings', {
template: '#app-settings-template',
@ -192,3 +221,78 @@
</script>
</%def>
% if use_buefy:
${parent.body()}
% else:
## legacy / not buefy
<div class="form">
${h.form(form.action_url, id=dform.formid, method='post', class_='autodisable')}
${h.csrf_token(request)}
% if dform.error:
<div class="error-messages">
<div class="ui-state-error ui-corner-all">
<span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
Please see errors below.
</div>
<div class="ui-state-error ui-corner-all">
<span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
${dform.error}
</div>
</div>
% endif
<div class="group-picker">
<div class="field-wrapper">
<label for="settings-group">Showing Group</label>
<div class="field select">
${h.select('settings-group', current_group, group_options, **{'auto-enhance': 'true'})}
</div>
</div>
</div>
% for group in groups:
<div class="panel" data-groupname="${group}">
<h2>${group}</h2>
<div class="panel-body">
% for setting in settings:
% if setting.group == group:
<% field = dform[setting.node_name] %>
<div class="field-wrapper ${field.name} ${'with-error' if field.error else ''}">
% if field.error:
<div class="field-error">
% for msg in field.error.messages():
<span class="error-msg">${msg}</span>
% endfor
</div>
% endif
<div class="field-row">
<label for="${field.oid}">${form.get_label(field.name)}</label>
<div class="field">
${field.serialize()|n}
</div>
</div>
% if form.has_helptext(field.name):
<span class="instructions">${form.render_helptext(field.name)}</span>
% endif
</div>
% endif
% endfor
</div><!-- panel-body -->
</div><! -- panel -->
% endfor
<div class="buttons">
${h.submit('save', getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")), class_='button is-primary')}
${h.link_to("Cancel", form.cancel_url, class_='cancel button{}'.format(' autodisable' if form.auto_disable_cancel else ''))}
</div>
${h.end_form()}
</div>
% endif

View file

@ -1,5 +1,63 @@
## -*- coding: utf-8; -*-
## TODO: This function signature is getting out of hand...
<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', select=None, selected=None, cleared=None, change_clicked=None, options={})">
<div id="${field_name}-container" class="autocomplete-container">
${h.hidden(field_name, id=field_name, value=field_value)}
${h.text(field_name+'-textbox', id=field_name+'-textbox', value=field_display,
class_='autocomplete-textbox', style='display: none;' if field_value else '')}
<div id="${field_name}-display" class="autocomplete-display"${'' if field_value else ' style="display: none;"'|n}>
<span>${field_display or ''}</span>
<button type="button" id="${field_name}-change" class="autocomplete-change">Change</button>
</div>
</div>
<script type="text/javascript">
$(function() {
$('#${field_name}-textbox').autocomplete({
source: '${service_url}',
autoFocus: true,
% for key, value in options.items():
${key}: ${value},
% endfor
focus: function(event, ui) {
return false;
},
% if select:
select: ${select}
% else:
select: function(event, ui) {
$('#${field_name}').val(ui.item.value);
$('#${field_name}-display span:first').text(ui.item.label);
$('#${field_name}-textbox').hide();
$('#${field_name}-display').show();
% if selected:
${selected}(ui.item.value, ui.item.label);
% endif
return false;
}
% endif
});
$('#${field_name}-change').click(function() {
% if change_clicked:
if (! ${change_clicked}()) {
return false;
}
% endif
$('#${field_name}').val('');
$('#${field_name}-display').hide();
with ($('#${field_name}-textbox')) {
val('');
show();
focus();
}
% if cleared:
${cleared}();
% endif
});
});
</script>
</%def>
<%def name="tailbone_autocomplete_template()">
<script type="text/x-template" id="tailbone-autocomplete-template">
<div>

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/base_meta.mako" />
<%def name="app_title()">${app.get_node_title()}</%def>
<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")}</%def>
<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def>
<%def name="favicon()">
<link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" />
@ -10,3 +11,9 @@
<%def name="header_logo()">
${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")}
</%def>
<%def name="footer()">
<p class="has-text-centered">
powered by ${h.link_to("Rattail", url('about'))}
</p>
</%def>

View file

@ -68,7 +68,7 @@
% endif
</%def>
<%def name="render_form()">
<%def name="render_buefy_form()">
<div class="form">
<tailbone-form></tailbone-form>
<br />
@ -76,5 +76,12 @@
</div>
</%def>
<%def name="render_form()">
${parent.render_form()}
% if not use_buefy:
${self.field_diff_table()}
% endif
</%def>
${parent.body()}

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