Compare commits

...

791 commits

Author SHA1 Message Date
e150453801 fix: add startup hack for tempmon DB model 2025-03-05 10:34:52 -06:00
e2582ffec5 bump: version 0.22.6 → 0.22.7 2025-02-19 10:33:39 -06:00
a6508154cb docs: update intersphinx doc links per server migration 2025-02-18 12:13:28 -06:00
7348eec671 fix: stop using old config for logo image url on login page 2025-02-18 11:16:23 -06:00
4221fa50dd fix: fix warning msg for deprecated Grid param 2025-02-14 11:37:21 -06:00
e0ebd43e7a bump: version 0.22.5 → 0.22.6 2025-02-01 15:18:12 -06:00
c7ee9de9eb fix: register vue3 form component for products -> make batch 2024-12-28 16:43:22 -06:00
950db697a0 bump: version 0.22.4 → 0.22.5 2024-12-16 12:46:45 -06:00
358b3b75a5 fix: whoops this is latest rattail 2024-12-10 13:05:32 -06:00
7e559a01b3 fix: require newer rattail lib 2024-12-10 12:52:49 -06:00
23bdde245a fix: require newer wuttaweb 2024-12-10 12:34:34 -06:00
2c269b640b fix: let caller request safe HTML literal for rendered grid table
mostly just for convenience
2024-12-01 18:12:30 -06:00
Lance Edgar
f1c8ffedda bump: version 0.22.3 → 0.22.4 2024-11-22 12:57:04 -06:00
Lance Edgar
aace6033c5 fix: avoid error in product search for duplicated key 2024-11-20 20:17:21 -06:00
Lance Edgar
7171c7fb06 fix: use vmodel for confirm password widget input
since previously this did not work at all for butterball (vue3 +
oruga) - although it was never clear why per se..

Refs: #1
2024-11-19 20:53:23 -06:00
Lance Edgar
993f066f2c bump: version 0.22.2 → 0.22.3 2024-11-19 15:45:37 -06:00
Lance Edgar
980031f524 fix: avoid error for trainwreck query when not a customer
when viewing a person's profile, who does not have a customer record,
the trainwreck query can't really return anything since it normally
should be matching on the customer ID
2024-11-18 14:59:50 -06:00
Lance Edgar
bcaf0d08bc bump: version 0.22.1 → 0.22.2 2024-11-18 14:08:10 -06:00
Lance Edgar
ac439c949b fix: use local/custom enum for continuum operations
since we can't rely on that existing in rattail proper, due to it not
always having sqlalchemy
2024-11-12 19:45:24 -06:00
Lance Edgar
20b3f87dbe fix: add basic master view for Product Costs 2024-11-12 18:30:50 -06:00
Lance Edgar
9e55717041 fix: show continuum operation type when viewing version history 2024-11-12 18:28:41 -06:00
Lance Edgar
772b6610cb fix: always define app attr for ViewSupplement 2024-11-12 18:26:36 -06:00
Lance Edgar
3f27f626df fix: avoid deprecated import 2024-11-10 19:16:45 -06:00
Lance Edgar
29743e70b7 bump: version 0.22.0 → 0.22.1 2024-11-02 16:56:28 -05:00
Lance Edgar
54220601ed fix: fix submit button for running problem report
esp. on Chrome(-based) browsers
2024-11-01 17:47:46 -05:00
Lance Edgar
9a6f8970ae fix: avoid deprecated grid method 2024-10-23 09:46:14 -05:00
Lance Edgar
28f90ad6b5 bump: version 0.21.11 → 0.22.0 2024-10-22 17:09:29 -05:00
Lance Edgar
535317e4f7 fix: avoid deprecated method to suggest username 2024-10-22 15:04:40 -05:00
Lance Edgar
072db39233 feat: add support for new ordering batch from parsed file 2024-10-22 14:26:10 -05:00
Lance Edgar
c6365f2631 bump: version 0.21.10 → 0.21.11 2024-10-03 09:05:46 -05:00
Lance Edgar
d520f64fee fix: custom method for adding grid action
since for now, we are using custom grid action class
2024-10-03 08:56:52 -05:00
Lance Edgar
2308d2e240 fix: become/stop root should redirect to previous url
for default theme; butterball already did that
2024-09-16 12:55:58 -05:00
Lance Edgar
0b4efae392 bump: version 0.21.9 → 0.21.10 2024-09-15 10:56:01 -05:00
Lance Edgar
0b646d2d18 fix: update project repo links, kallithea -> forgejo 2024-09-14 12:49:37 -05:00
Lance Edgar
a4d81a6e3c docs: use markdown for readme file 2024-09-13 18:16:07 -05:00
Lance Edgar
5e742eab17 fix: use better icon for submit button on login page 2024-09-09 08:32:28 -05:00
Lance Edgar
b9b8bbd2ea fix: wrap notes text for batch view 2024-08-29 17:18:32 -05:00
Lance Edgar
8df52bf2a2 fix: expose datasync consumer batch size via configure page 2024-08-29 17:01:49 -05:00
Lance Edgar
55f45ae8a0 bump: version 0.21.8 → 0.21.9 2024-08-28 17:38:33 -05:00
Lance Edgar
2219cf8198 fix: render custom attrs in form component tag 2024-08-28 17:38:05 -05:00
Lance Edgar
9be2f63475 bump: version 0.21.7 → 0.21.8 2024-08-28 14:37:40 -05:00
Lance Edgar
812d8d2349 fix: ignore session kwarg for MasterView.make_row_grid() 2024-08-28 14:37:18 -05:00
Lance Edgar
20dcdd8b86 bump: version 0.21.6 → 0.21.7 2024-08-28 14:20:51 -05:00
Lance Edgar
bc399182ba fix: avoid error when form value cannot be obtained 2024-08-28 14:20:17 -05:00
Lance Edgar
71d63f6b93 bump: version 0.21.5 → 0.21.6 2024-08-28 09:53:37 -05:00
Lance Edgar
0b6cfaa9c5 fix: avoid error when grid value cannot be obtained 2024-08-28 09:53:14 -05:00
Lance Edgar
b81914fbf5 test: fix broken test 2024-08-28 00:35:15 -05:00
Lance Edgar
b30f066c41 bump: version 0.21.4 → 0.21.5 2024-08-28 00:30:15 -05:00
Lance Edgar
2e20fc5b75 fix: set empty string for "-new-" file configure option
otherwise the "-new-" option is not properly auto-selected
2024-08-27 13:50:30 -05:00
Lance Edgar
ca05e68890 bump: version 0.21.3 → 0.21.4 2024-08-26 16:12:14 -05:00
Lance Edgar
7a9d5772db fix: handle differing email profile keys for appinfo/configure
hopefully this all can improve some day soon..
2024-08-26 16:11:32 -05:00
Lance Edgar
dffd951369 bump: version 0.21.2 → 0.21.3 2024-08-26 15:25:56 -05:00
Lance Edgar
d67eb2f1cc fix: show non-standard config values for app info configure email
this page is currently showing some basic email sender/recips etc. but
the config keys traditionally used by rattail are different than
wuttjamaican..so for now we must "translate"
2024-08-26 15:24:40 -05:00
Lance Edgar
3a9bf69aa7 bump: version 0.21.1 → 0.21.2 2024-08-26 14:56:15 -05:00
Lance Edgar
d1f4c0f150 fix: refactor waterpark base template to use wutta feedback component
although for now we still provide the template and add reply-to
2024-08-26 14:54:45 -05:00
Lance Edgar
b7991b5dc6 fix: fix input/output file upload feature for configure pages, per oruga 2024-08-23 16:18:17 -05:00
Lance Edgar
c1a2c9cc70 fix: tweak how grid data translates to Vue template context
per wuttaweb changes
2024-08-23 14:14:17 -05:00
Lance Edgar
37f760959d fix: merge filters into main grid template
to better match wuttaweb
2024-08-22 19:58:27 -05:00
Lance Edgar
cea3e4b927 fix: add basic wutta view for users
just proving concepts still at this point..nothing reliable
2024-08-22 19:40:21 -05:00
Lance Edgar
29531c83c4 fix: some fixes for wutta people view 2024-08-22 19:21:48 -05:00
Lance Edgar
4c3e3aeb6a fix: various fixes for waterpark theme 2024-08-22 17:09:58 -05:00
Lance Edgar
c176d97870 fix: avoid deprecated component form kwarg 2024-08-22 15:54:15 -05:00
Lance Edgar
7d6f75bb05 bump: version 0.21.0 → 0.21.1 2024-08-22 15:33:28 -05:00
Lance Edgar
7b40c527c8 fix: misc. bugfixes per recent changes 2024-08-22 15:31:09 -05:00
Lance Edgar
f292850d05 test: fix some tests 2024-08-22 14:59:18 -05:00
Lance Edgar
8d5427e92f bump: version 0.20.1 → 0.21.0 2024-08-22 14:53:59 -05:00
Lance Edgar
b8131c8393 fix: change grid reset-view param name to match wuttaweb 2024-08-22 13:49:57 -05:00
Lance Edgar
e52a83751e feat: move "most" filtering logic for grid class to wuttaweb
we still define all filters, and the "most important" grid methods for
filtering
2024-08-21 20:16:03 -05:00
Lance Edgar
ffa724ef37 fix: move "searchable columns" grid feature to wuttaweb 2024-08-21 15:52:30 -05:00
Lance Edgar
1d00fe994a fix: use wuttaweb to get/render csrf token 2024-08-21 09:44:32 -05:00
Lance Edgar
71abbe06da feat: inherit from wuttaweb templates for home, login pages 2024-08-21 00:49:26 -05:00
Lance Edgar
f755460242 feat: inherit from wuttaweb for AppInfoView, appinfo/configure template 2024-08-21 00:49:23 -05:00
Lance Edgar
2ffc067097 fix: inherit from wuttaweb for appinfo/index template
although for now, still must override for some link buttons
2024-08-20 22:27:46 -05:00
Lance Edgar
b6a8e508bf fix: prefer wuttaweb config for "home redirect to login" feature 2024-08-20 22:16:01 -05:00
Lance Edgar
1def26a35b feat: add "has output file templates" config option for master view
this is a bit hacky, a quick copy/paste job from the equivalent
feature for input file templates.

i assume this will get cleaned up when moved to wuttaweb..
2024-08-20 19:09:56 -05:00
Lance Edgar
07871188aa fix: fix master/index template rendering for waterpark theme 2024-08-20 17:03:57 -05:00
Lance Edgar
c8dc60cb68 fix: fix spacing for navbar logo/title in waterpark theme 2024-08-20 16:49:34 -05:00
Lance Edgar
526c84dfa6 bump: version 0.20.0 → 0.20.1 2024-08-20 16:05:52 -05:00
Lance Edgar
21f90f3f32 fix: fix default filter verbs logic for workorder status 2024-08-20 16:02:35 -05:00
Lance Edgar
83586ef90f bump: version 0.19.3 → 0.20.0 2024-08-20 15:06:09 -05:00
Lance Edgar
59bd58aca7 feat: add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy
hoping to eventually replace the 'default' view with this one, if all
goes well.  definitely needs more testing and is not exposed as an
option yet, unless configured
2024-08-20 15:03:25 -05:00
Lance Edgar
1ec1eba496 feat: refactor templates to simplify base/page/form structure
to mimic what has been done in wuttaweb
2024-08-19 23:20:59 -05:00
Lance Edgar
d29b840343 fix: avoid deprecated reference to app db engine 2024-08-19 14:38:41 -05:00
Lance Edgar
b762a0782a bump: version 0.19.2 → 0.19.3 2024-08-19 13:57:36 -05:00
Lance Edgar
15ab0c9592 fix: add pager stats to all grid vue data (fixes view history)
also various other tweaks to modernize
2024-08-19 13:48:18 -05:00
Lance Edgar
41945c5e37 bump: version 0.19.1 → 0.19.2 2024-08-19 12:01:42 -05:00
Lance Edgar
f5661fe349 fix: sort on frontend for appinfo package listing grid 2024-08-19 11:56:46 -05:00
Lance Edgar
0eeeb4bd35 fix: prefer attr over key lookup when getting model values
applies to both forms and grids.

the base model class can still handle `obj[key]` but now it is limited
to the column fields only, no association proxies.

so, better to just try `getattr(obj, key)` first and only fall back to
the other if it fails.

unless the obj is clearly a dict in which case try `obj[key]` only
2024-08-19 11:49:52 -05:00
Lance Edgar
1d56a4c0d0 fix: replace all occurrences of component_studly => vue_component 2024-08-19 09:53:10 -05:00
Lance Edgar
b642c98d40 bump: version 0.19.0 → 0.19.1 2024-08-19 09:23:55 -05:00
Lance Edgar
0fb3c0f3d2 fix: fix broken user auth for web API app 2024-08-19 09:23:31 -05:00
Lance Edgar
b7955a5871 bump: version 0.18.0 → 0.19.0 2024-08-18 19:58:50 -05:00
Lance Edgar
290f8fd51e feat: move multi-column grid sorting logic to wuttaweb
tailbone grid template still duplicates much for Vue, and will until
we can port the filters and anything else remaining..
2024-08-18 19:52:21 -05:00
Lance Edgar
ec36df4a34 feat: move single-column grid sorting logic to wuttaweb 2024-08-18 14:05:52 -05:00
Lance Edgar
c95e42bf82 fix: fix misc. errors in grid template per wuttaweb 2024-08-18 10:20:48 -05:00
Lance Edgar
5e82fe3946 fix: fix broken permission directives in web api startup 2024-08-18 10:20:48 -05:00
Lance Edgar
f4c8176d83 bump: version 0.17.0 → 0.18.0 2024-08-16 22:54:22 -05:00
Lance Edgar
9da2a148c6 feat: move "basic" grid pagination logic to wuttaweb
so far only "simple" pagination is supported by wuttaweb, so basically
the main feature flag, page size, current page.  in this
scenario *all* data is written to client-side JSON and Buefy handles
the actual pagination.

backend pagination coming soon for wuttaweb but for now tailbone still
handles all that.
2024-08-16 18:45:04 -05:00
Lance Edgar
2a0b6da2f9 feat: inherit from wutta base class for Grid 2024-08-16 14:34:50 -05:00
Lance Edgar
f7641218cb fix: avoid route error in user view, when using wutta people view
kind of a temporary edge case here, can eventually change it back
2024-08-16 11:56:54 -05:00
Lance Edgar
1b78bd617c feat: inherit most logic from wuttaweb, for GridAction 2024-08-16 11:56:12 -05:00
Lance Edgar
09612b1921 fix: fix some more wutta compat for base template
missed those earlier
2024-08-15 23:46:58 -05:00
Lance Edgar
bbd98e7b2f bump: version 0.16.1 → 0.17.0 2024-08-15 23:15:25 -05:00
Lance Edgar
da0f6bd5e1 feat: use wuttaweb for get_liburl() logic
thankfully this is already handled and we can remove from tailbone.
although this adds some new cruft as well, to handle auto-migrating
any existing liburl config for apps.

eventually once all apps have migrated to new settings we can remove
the prefix from our calls here but also in wuttaweb signature
2024-08-15 23:12:02 -05:00
Lance Edgar
bbc2c584ec bump: version 0.16.0 → 0.16.1 2024-08-15 21:16:53 -05:00
Lance Edgar
7f0c571a44 fix: improve wutta People view a bit
try to behave more like traditional tailbone, for the few things
supported so far.  taking a conservative approach here for now since
probably other things are more pressing.
2024-08-15 21:12:34 -05:00
Lance Edgar
53040dc6be fix: update references to get_class_hierarchy()
per upstream changes
2024-08-15 20:29:36 -05:00
Lance Edgar
1cacfab2a6 fix: tweak template for people/view_profile per wutta compat
wutta has the view defined but it returns minimal context
2024-08-15 18:44:14 -05:00
Lance Edgar
bab09e3fe7 bump: version 0.15.6 → 0.16.0 2024-08-15 16:22:35 -05:00
Lance Edgar
dd176a5e9e feat: add first wutta-based master, for PersonView
still opt-in-only at this point, the traditional tailbone-native
master is used by default.

new wutta master is not feature complete yet.  but at least things
seem to render and form posts work okay..

when enabled, this uses a "completely" wutta-based stack for the view,
grid and forms.  but the underlying DB is of course rattail, and the
templates are still traditional/tailbone.
2024-08-15 16:05:53 -05:00
Lance Edgar
a6ce5eb21d feat: refactor forms/grids/views/templates per wuttaweb compat
this starts to get things more aligned between wuttaweb and tailbone.
the use case in mind so far is for a wuttaweb view to be included in a
tailbone app.

form and grid classes now have some new methods to match wuttaweb, so
templates call the shared method names where possible.

templates can no longer assume they have tailbone-native master view,
form, grid etc. so must inspect context more closely in some cases.
2024-08-15 15:49:54 -05:00
Lance Edgar
b53479f8e4 bump: version 0.15.5 → 0.15.6 2024-08-13 11:21:38 -05:00
Lance Edgar
1f752530d2 fix: avoid before_render subscriber hook for web API
the purpose of that function is to setup extra template context, but
API views always render as 'json' with no template
2024-08-10 13:49:41 -05:00
Lance Edgar
2c46fde742 fix: simplify verbiage for batch execution panel 2024-08-10 08:43:54 -05:00
Lance Edgar
d57efba381 bump: version 0.15.4 → 0.15.5 2024-08-09 19:48:51 -05:00
Lance Edgar
f2fce2e305 fix: assign convenience attrs for all views (config, app, enum, model) 2024-08-09 19:22:26 -05:00
Lance Edgar
b5f0ecb165 bump: version 0.15.3 → 0.15.4 2024-08-09 10:13:00 -05:00
Lance Edgar
7e683dfc4a fix: avoid bug when checking current theme
this check is happening not only for classic views but API as well,
which doesn't really have a theme..  probably need a proper fix in
wuttaweb but this should be okay for now
2024-08-09 10:11:38 -05:00
Lance Edgar
0b8315fc78 bump: version 0.15.2 → 0.15.3 2024-08-08 19:39:36 -05:00
Lance Edgar
ffd694e7b7 fix: fix timepicker parseTime() when value is null 2024-08-08 19:39:01 -05:00
Lance Edgar
80dc4eb7a9 bump: version 0.15.1 → 0.15.2 2024-08-06 23:19:14 -05:00
Lance Edgar
518c108c88 fix: use auth handler, avoid legacy calls for role/perm checks 2024-08-06 10:36:20 -05:00
Lance Edgar
bd1993f440 bump: version 0.15.0 → 0.15.1 2024-08-05 22:57:02 -05:00
Lance Edgar
91ea9021d7 fix: move magic b template context var to wuttaweb 2024-08-05 21:50:22 -05:00
Lance Edgar
2903b376b5 bump: version 0.14.5 → 0.15.0 2024-08-05 15:35:06 -05:00
Lance Edgar
9d2684046f feat: move more subscriber logic to wuttaweb 2024-08-05 15:00:11 -05:00
Lance Edgar
3b92bb3a9e fix: use wuttaweb logic for util.get_form_data() 2024-08-05 09:11:19 -05:00
Lance Edgar
5ec899cf08 bump: version 0.14.4 → 0.14.5 2024-08-03 17:43:46 -05:00
Lance Edgar
458c95696a fix: use auth handler instead of deprecated auth functions 2024-08-03 14:13:16 -05:00
Lance Edgar
08a89c490a fix: avoid duplicate partial param when grid reloads data 2024-07-21 20:20:43 -05:00
Lance Edgar
a9495b6a70 bump: version 0.14.3 → 0.14.4 2024-07-18 17:59:55 -05:00
Lance Edgar
1bba6d9947 fix: fix more settings persistence bug(s) for datasync/configure
esp. for the profile consumers info
2024-07-18 17:58:59 -05:00
Lance Edgar
f4f79f170a fix: fix modals for luigi tasks page, per oruga 2024-07-17 19:45:47 -05:00
Lance Edgar
9c466796da bump: version 0.14.2 → 0.14.3 2024-07-17 18:24:21 -05:00
Lance Edgar
e88b8fc9bc fix: fix auto-collapse title for viewing trainwreck txn 2024-07-16 21:21:43 -05:00
Lance Edgar
3aafe578f0 fix: allow auto-collapse of header when viewing trainwreck txn 2024-07-16 18:59:35 -05:00
Lance Edgar
af0f84762c bump: version 0.14.1 → 0.14.2 2024-07-15 21:52:05 -05:00
Lance Edgar
be6eb5f815 fix: add null menu handler, for use with API apps 2024-07-15 21:51:45 -05:00
Lance Edgar
57fdacdb83 bump: version 0.14.0 → 0.14.1 2024-07-14 23:29:35 -05:00
Lance Edgar
ece29d7b6c fix: update usage of auth handler, per rattail changes 2024-07-14 23:29:17 -05:00
Lance Edgar
5e1c0a5187 fix: fix model reference in menu handler 2024-07-14 12:41:08 -05:00
Lance Edgar
25e62fe6ef fix: fix bug when making "integration" menus
per recent refactor
2024-07-14 11:47:15 -05:00
Lance Edgar
d70bac74f0 bump: version 0.13.2 → 0.14.0 2024-07-14 11:12:32 -05:00
Lance Edgar
fd1ec01128 feat: move core menu logic to wuttaweb
tailbone still defines the default menus, and allows for making dynamic
menus from config (which wuttaweb does not).

also remove some even older logic for "v1" menu functions
2024-07-14 11:05:01 -05:00
Lance Edgar
0b4629ea29 bump: version 0.13.1 → 0.13.2 2024-07-13 15:28:59 -05:00
Lance Edgar
27214cc62f fix: fix logic bug for datasync/config settings save
dang it
2024-07-13 15:28:28 -05:00
Lance Edgar
d2d0206b45 build: run pytest but avoid tox when preparing release
buildbot can let us know if something goes wrong with an atypical
python version etc.
2024-07-13 15:16:45 -05:00
Lance Edgar
eede274529 bump: version 0.13.0 → 0.13.1 2024-07-13 15:15:51 -05:00
Lance Edgar
ee781ec489 fix: fix settings persistence bug(s) for datasync/configure page
also hide the Changes context menu link, within the Configure page
2024-07-13 15:14:04 -05:00
Lance Edgar
ca660f4087 bump: version 0.12.1 → 0.13.0 2024-07-12 09:38:12 -05:00
Lance Edgar
ce156d6278 feat: begin integrating WuttaWeb as upstream dependency
the bare minimum, just to get the relationship established.  mostly
it's calling upstream subscriber / event hooks where applicable.

this also overhauls the docs config to use furo theme etc.
2024-07-12 09:35:34 -05:00
Lance Edgar
e531f98079 fix: cast enum as list to satisfy deform widget
seems to only be an issue for deform 2.0.15+
2024-07-11 13:54:37 -05:00
Lance Edgar
09ce2d5a40 bump: version 0.12.0 → 0.12.1 2024-07-11 13:16:36 -05:00
Lance Edgar
ae8212069c fix: refactor config.get_model() => app.model
per rattail changes
2024-07-11 13:16:27 -05:00
Lance Edgar
4eb5866379 bump: version 0.11.10 → 0.12.0 2024-07-09 16:45:45 -05:00
Lance Edgar
a86a33445e feat: drop python 3.6 support, use pyproject.toml (again) 2024-07-09 16:45:36 -05:00
Lance Edgar
12f8b7bdf7 bump: version 0.11.9 → 0.11.10 2024-07-05 14:58:02 -05:00
Lance Edgar
2f2ebd0f07 fix: make the Members tab optional, for profile view
and hidden by default
2024-07-05 14:57:19 -05:00
Lance Edgar
2917463bb6 bump: version 0.11.8 → 0.11.9 2024-07-05 14:49:59 -05:00
Lance Edgar
16bf13787d fix: add optional Transactions tab for profile view
showing Trainwreck data by default
2024-07-05 14:45:35 -05:00
Lance Edgar
b7d26b6b8c fix: add xref button to customer profile, for trainwreck txn view 2024-07-05 14:30:52 -05:00
Lance Edgar
19e65f5bb9 fix: expand input for butterball theme 2024-07-05 13:07:08 -05:00
Lance Edgar
735327e46b fix: improve collapse panels for butterball theme 2024-07-05 12:53:14 -05:00
Lance Edgar
2988ff3ee9 fix: do not show flash message when changing app theme
it is just distracting esp. when testing different themes
2024-07-05 12:50:45 -05:00
Lance Edgar
431a4d7433 bump: version 0.11.7 → 0.11.8 2024-07-04 23:59:06 -05:00
Lance Edgar
58be7e9d5b fix: add tool to make user account from profile view 2024-07-04 21:32:46 -05:00
Lance Edgar
ddec77c37f fix: leverage import handler method to determine command/subcommand
just moved previous logic to rattail/handler
2024-07-04 20:35:14 -05:00
Lance Edgar
89d7009a18 fix: allow view supplements to add extra links for profile employee tab 2024-07-04 18:21:06 -05:00
Lance Edgar
793a15883e fix: fix grid action icons for datasync/configure, per oruga 2024-07-04 15:59:05 -05:00
Lance Edgar
76897c24de bump: version 0.11.6 → 0.11.7 2024-07-04 08:20:31 -05:00
Lance Edgar
5e11a2ecf6 fix: expand POD image URL setting input 2024-07-02 22:47:03 -05:00
Lance Edgar
e23193b730 fix: cast enum as list to satisfy deform widget
seems to only be an issue for deform 2.0.15+
2024-07-02 16:45:10 -05:00
Lance Edgar
9146cdc835 fix: allow view supplements to add to profile member context 2024-07-02 14:20:48 -05:00
Lance Edgar
1f38894f02 fix: include edit profile email/phone dialogs only if user has perms
otherwise we get JS errors when page loads
2024-07-02 14:14:15 -05:00
Lance Edgar
d72d6f8c7c fix: require zope.sqlalchemy >= 1.5
so we can do away with some old cruft, since latest zope.sqlalchemy is
3.1 from 2023-09-12
2024-07-02 11:14:03 -05:00
Lance Edgar
aab4dec27e fix: add stacklevel to deprecation warnings 2024-07-02 09:05:51 -05:00
Lance Edgar
db67630363 bump: version 0.11.5 → 0.11.6 2024-07-01 23:20:09 -05:00
Lance Edgar
c887412825 fix: fix syntax bug 2024-07-01 19:06:04 -05:00
Lance Edgar
2feb07e1d3 fix: remove references, dependency for six package 2024-07-01 17:01:01 -05:00
Lance Edgar
6f8b825b0b fix: set explicit referrer when changing dbkey
since for some reason HTTP_REFERER is not always set now??
2024-07-01 15:23:56 -05:00
Lance Edgar
cad50c9149 bump: version 0.11.4 → 0.11.5 2024-06-30 21:28:56 -05:00
Lance Edgar
d6939e52b4 fix: use vue 3.4.31 and oruga 0.8.12 by default
i.e. for butterball theme

cf. https://github.com/oruga-ui/oruga/issues/974#issuecomment-2198573369
2024-06-30 18:25:01 -05:00
Lance Edgar
3f7de5872e fix: add custom url prefix if needed, for fanstatic 2024-06-30 12:40:03 -05:00
Lance Edgar
1dc632174e fix: allow comma in numeric filter input
just remove them and run with the remainder, on the SQL side
2024-06-30 11:44:33 -05:00
Lance Edgar
eff5341335 bump: version 0.11.3 → 0.11.4 2024-06-30 10:49:54 -05:00
Lance Edgar
83e4d95741 fix: don't escape each address for email attempts grid
now that we are properly escaping the full cell value, no need
2024-06-30 10:32:05 -05:00
Lance Edgar
9b6447c4cb fix: require vendor when making new ordering batch via api
pretty sure this pattern needs to be expanded and probably improved,
but wanted to fix this one scenario for now, per error email
2024-06-28 17:58:27 -05:00
Lance Edgar
ec5ed490d9 fix: start/stop being root should submit POST instead of GET
obviously it's access-restricted anyway but this just seems more correct

but more importantly this makes the referrer explicit, since for some
unknown reason i am suddenly seeing that be blank for certain installs
where that wasn't the case before (?) - and the result was that every
time you start/stop being root you would be redirected to home page
instead of remaining on current page
2024-06-28 17:35:54 -05:00
Lance Edgar
d17bd35909 bump: version 0.11.2 → 0.11.3 2024-06-28 15:39:59 -05:00
Lance Edgar
3b7cc19faa fix: handle error when merging 2 records fails
should give the user some idea of the problem instead of just sending
error email to admins
2024-06-28 15:36:08 -05:00
Lance Edgar
067ca5bd43 fix: add link to "resolved by" user for pending products 2024-06-27 23:11:13 -05:00
Lance Edgar
525a28f3fe bump: version 0.11.1 → 0.11.2 2024-06-18 18:05:05 -05:00
Lance Edgar
a0cd8835e0 fix: show flash error message if resolve pending product fails 2024-06-18 16:12:24 -05:00
Lance Edgar
231ca0363a fix: product records should be touchable 2024-06-18 16:06:55 -05:00
Lance Edgar
88e7d86087 fix: use different logic for buefy/oruga for product lookup keydown
i could have swore the new logic worked with buefy..but today it didn't
2024-06-18 15:04:05 -05:00
Lance Edgar
0212e52b66 fix: hide certain custorder settings if not applicable 2024-06-14 19:59:52 -05:00
Lance Edgar
da4450b574 build: avoid version parse when uploading release 2024-06-14 18:02:39 -05:00
Lance Edgar
ab4dbbedf0 bump: version 0.11.0 → 0.11.1 2024-06-14 18:01:40 -05:00
Lance Edgar
6e741f6156 fix: revert back to setup.py + setup.cfg
apparently with python 3.6 things "mostly" work but then they break if
any specified dependencies have a dot in the name.  which in this
project, is the case for `zope.sqlalchemy`

so until we drop python 3.6 support, we cannot use pyproject.toml here
2024-06-14 17:57:01 -05:00
Lance Edgar
fb0c538a2b test: skip running tests for py36
we should soon require python 3.8 anyway
2024-06-10 17:42:29 -05:00
Lance Edgar
f9cb6cb59b bump: version 0.10.16 → 0.11.0 2024-06-10 16:40:55 -05:00
Lance Edgar
1402d437b5 feat: switch from setup.cfg to pyproject.toml + hatchling 2024-06-10 16:23:38 -05:00
Lance Edgar
dd58c640fa Update changelog 2024-06-10 11:11:06 -05:00
Lance Edgar
2c2727bf66 feat: standardize how app, package versions are determined 2024-06-10 09:14:20 -05:00
Lance Edgar
b8ace1eb98 fix: avoid deprecated config methods for app/node title 2024-06-09 23:07:52 -05:00
Lance Edgar
7c3d5b46f3 Update changelog 2024-06-07 10:25:48 -05:00
Lance Edgar
a849d8452b Update changelog 2024-06-07 10:25:14 -05:00
Lance Edgar
610e1666c0 Revert "Use pkg_resources to determine package versions"
This reverts commit f6f2a53a0c.
2024-06-07 10:07:31 -05:00
Lance Edgar
94d7836321 Ignore dist folder 2024-06-06 23:05:40 -05:00
Lance Edgar
0491d8517c Update changelog 2024-06-06 23:04:47 -05:00
Lance Edgar
f6f2a53a0c Use pkg_resources to determine package versions
and always add `app_version` to global template context.  this was for
sake of "About This App v1.0.0" style links in custom page footers
2024-06-06 20:34:31 -05:00
Lance Edgar
ce290f5f8b Update changelog 2024-06-06 15:30:48 -05:00
Lance Edgar
d9911cf23d Add 'fanstatic' support for sake of libcache assets
for vue.js and oruga etc.

we don't want to include files in tailbone since they are apt to
change over time, and probably need to use different versions for
different apps etc.

much may need to change yet, this is a first attempt but so far it
seems quite promising
2024-06-05 23:04:45 -05:00
Lance Edgar
1afc70e788 Remove old/unused scaffold for use with pcreate
we now have a better Generate Project feature
2024-06-04 22:11:51 -05:00
Lance Edgar
c189273471 Update changelog 2024-06-04 21:12:44 -05:00
Lance Edgar
22aceb4d67 Include butterball theme by default for new apps
but it is not "the" default yet..
2024-06-04 17:28:07 -05:00
Lance Edgar
da6ccf4425 Fix product lookup component, per butterball 2024-06-04 17:16:57 -05:00
Lance Edgar
d02bf0e5c7 Include extra styles from base_meta template for butterball 2024-06-04 17:15:42 -05:00
Lance Edgar
10aac388f0 Add <b-tooltip> component shim 2024-06-04 17:15:29 -05:00
Lance Edgar
00e2af1561 Set explicit referrer when changing app theme
to include url #hash value if there is one, so switching theme is more
seamless from the view profile page
2024-06-04 01:07:38 -05:00
Lance Edgar
6a7c06d26e Remove version cap for deform
see also commit 95dd8d83dc where i first
added the version cap; it mentions an error that i am not sure how to
reproduce.  so we'll see if there really is still an error..or if it
has since fixed itself
2024-06-03 23:16:00 -05:00
Lance Edgar
efe477d0db Require pyramid 2.x; remove 1.x-style auth policies 2024-06-03 23:13:25 -05:00
Lance Edgar
e17ef2edd8 Update changelog 2024-06-03 21:16:42 -05:00
Lance Edgar
30a8b8e5e4 Fix inventory worksheet generator, per butterball 2024-06-03 20:07:42 -05:00
Lance Edgar
2498da3909 Fix ordering worksheet generator, per butterball 2024-06-03 20:04:01 -05:00
Lance Edgar
2791e1c385 Fix grid bug for tempmon appliance view, per oruga 2024-06-03 19:51:16 -05:00
Lance Edgar
0303014acb Fix vue3 refresh for employee tab of profile view
and misc. related cleanup
2024-06-03 17:37:11 -05:00
Lance Edgar
9243edf7af Fix vue3 refresh for name, address cards in profile view 2024-06-03 16:51:29 -05:00
Lance Edgar
ab523719a6 Update changelog 2024-06-03 11:16:33 -05:00
Lance Edgar
b27987f1d1 More butterball fixes for "view profile" template 2024-06-03 11:15:22 -05:00
Lance Edgar
30238528fe Fix focus for <b-select> shim component 2024-06-03 10:48:59 -05:00
Lance Edgar
29c9ea1a2b Update changelog 2024-06-03 10:28:42 -05:00
Lance Edgar
58f9588261 Fix the "new custorder" page for butterball
reasonably confident this one is complete, and didn't break buefy theme..
2024-06-02 19:56:42 -05:00
Lance Edgar
3dc8deef67 Fix panel style for PO vs. Invoice breakdown in receiving batch 2024-06-02 15:58:10 -05:00
Lance Edgar
fa25857680 Let master view control context menu items for page
that really does not belong in the template if we can help it.  some
templates still define context menu items but can hopefully phase
those out over time
2024-06-02 15:54:42 -05:00
Lance Edgar
254df6d6f2 Update changelog 2024-06-02 14:55:18 -05:00
Lance Edgar
40edde2694 Use oruga 0.8.9 by default 2024-06-01 23:06:19 -05:00
Lance Edgar
9258237b85 Allow per-user custom styles for butterball 2024-06-01 21:37:38 -05:00
Lance Edgar
1bf28eb286 Fix product view template for oruga/butterball 2024-06-01 19:07:07 -05:00
Lance Edgar
77eeb63b62 Add styling for checked grid rows, per oruga/butterball 2024-06-01 17:46:35 -05:00
Lance Edgar
43db60bbee Update changelog 2024-06-01 14:26:17 -05:00
Lance Edgar
6b1c313efd Fix file upload widget for oruga 2024-06-01 14:23:35 -05:00
Lance Edgar
d05458c7fb Add speedbumps for delete, set preferred email/phone in profile view 2024-06-01 13:40:50 -05:00
Lance Edgar
b87b1a3801 Escape all unsafe html for grid data 2024-05-31 21:20:45 -05:00
Lance Edgar
ba519334d1 Fix overflow when instance header title is too long 2024-05-31 18:01:56 -05:00
Lance Edgar
3ac131cb51 Add column filters for import/export main grid 2024-05-31 14:51:01 -05:00
Lance Edgar
9b88f01378 Log error if registry has no rattail config
not clear if this is even possible, but if so i want to know about it

trying to figure out the occasional error email we get, latest being
from collectd/curl pinging the /login page, but request.has_perm()
call fails with missing attr?!

seems like either the rattail config is empty, or else the subscriber
events aren't firing (in the correct order) ?
2024-05-31 12:06:31 -05:00
Lance Edgar
49cd050272 Add setting to allow decimal quantities for receiving 2024-05-31 10:57:28 -05:00
Lance Edgar
0d8928bdf5 Update changelog 2024-05-29 22:15:39 -05:00
Lance Edgar
54b75dbe1a Fix basic problems with people profile view, per butterball
plenty more tweaks needed yet i'm sure, but page looks reasonable now
at least
2024-05-29 20:47:10 -05:00
Lance Edgar
b98d651144 Expose quickie lookup for butterball theme 2024-05-29 16:55:42 -05:00
Lance Edgar
9a841ba5e2 Expose db picker for butterball theme 2024-05-29 16:33:30 -05:00
Lance Edgar
4ccdf99a43 Add way to flag organic products within lookup dialog 2024-05-29 15:47:04 -05:00
Lance Edgar
f8ab8d462c Update changelog 2024-05-29 09:40:50 -05:00
Lance Edgar
fb9bc01939 Add <tailbone-timepicker> component for oruga 2024-05-14 12:17:50 -05:00
Lance Edgar
ec61444b3d Update changelog 2024-05-12 17:41:09 -05:00
Lance Edgar
66304a418e Fix styles for grid actions, per butterball 2024-05-10 15:50:33 -05:00
Lance Edgar
6bb6c16bc7 Update changelogo 2024-05-10 15:00:21 -05:00
Lance Edgar
c43deb1307 Fix bug with grid date filters 2024-05-08 20:50:54 -05:00
Lance Edgar
b65b514270 Update changelog 2024-05-08 11:17:58 -05:00
Lance Edgar
9b65e18261 Tweak styles for grid action links, per butterball 2024-05-08 11:16:16 -05:00
Lance Edgar
b40423fc2d Fix "view receiving row" page, per oruga
all the buttons and tools *should* work correctly for Vue 2 and 3 now
2024-05-07 20:44:26 -05:00
Lance Edgar
28fb3f44a7 More data type fixes for <tailbone-datepicker>
traditionally the caller has always dealt with string values only, so
the component should never emit events with date values, etc.
2024-05-07 18:20:26 -05:00
Lance Edgar
d607ab2981 Fix display for "view receiving row" page, per oruga
this page still needs help; "Account for Product" is broken for oruga
2024-05-07 12:43:07 -05:00
Lance Edgar
9cd648f78f Fix button text for autocomplete
whoops i think that was a debug thing i forgot to remove
2024-05-07 11:57:00 -05:00
Lance Edgar
703d583f6f Fix "tools" helper for receiving batch view, per oruga 2024-05-07 11:56:57 -05:00
Lance Edgar
f0d694cfe5 Rename some attrs etc. for buefy components used with oruga 2024-05-06 22:56:47 -05:00
Lance Edgar
3d319cbd09 Fix login "enter" key behavior, per oruga 2024-05-06 22:13:43 -05:00
Lance Edgar
e4c4259674 Remove version restriction for pyramid_beaker dependency
latest version is 0.9, so this wasn't all that relevant
2024-05-06 21:53:11 -05:00
Lance Edgar
15fedf5976 Fix employees grid when viewing department (per oruga) 2024-05-06 21:52:53 -05:00
Lance Edgar
68384a00dc Update changelog 2024-04-28 20:25:55 -05:00
Lance Edgar
e9ddd6dc36 Stop including 'falafel' as available theme 2024-04-28 20:21:20 -05:00
Lance Edgar
6ce65badeb Show "View This" button when cloning a record 2024-04-28 20:12:49 -05:00
Lance Edgar
9ee6521d6a Fix upgrade execution logic/UI per oruga 2024-04-28 20:12:06 -05:00
Lance Edgar
72f48fa963 Fix vertical alignment in main menu bar, for butterball 2024-04-28 19:30:35 -05:00
Lance Edgar
b3784dcc4a Update various icon names for oruga compatibility 2024-04-28 18:49:11 -05:00
Lance Edgar
34878f9293 Sort list of available themes
and add `computed` attr for WholePage; needed by some customizations
2024-04-28 18:37:00 -05:00
Lance Edgar
adaa39f572 Update changelog 2024-04-28 17:33:06 -05:00
Lance Edgar
1d5a0630ef Change default URL for some vue3+oruga libs
apparently the first ones were not ideal / optimized, but these are
2024-04-28 16:16:38 -05:00
Lance Edgar
855fa7e1e2 Fix centering for "Show Totals" grid tool 2024-04-28 02:41:45 -05:00
Lance Edgar
f2f023e7b3 Fix v-model handling for grid-filter-numeric-value 2024-04-28 02:39:40 -05:00
Lance Edgar
33251e880e Fix oruga styles for batch view
also use typical panels, for row status breakdown etc.
2024-04-28 01:58:19 -05:00
Lance Edgar
358816d9e7 Add oruga overhead for "classic" app only, not API 2024-04-28 00:51:07 -05:00
Lance Edgar
362d545f34 Fix modal state for appinfo/configure page 2024-04-28 00:25:03 -05:00
Lance Edgar
fb81a8302c Use oruga 0.8.7 by default instead of latest 0.8.8
until the new bug is fixed, https://github.com/oruga-ui/oruga/issues/913
2024-04-28 00:20:43 -05:00
Lance Edgar
e7a44d9979 Let caller use string data for <tailbone-datepicker>
don't require a Date object, since callers thus far have not expected that
2024-04-27 21:54:55 -05:00
Lance Edgar
2eaeb1891d Add initial support for Vue 3 + Oruga, via "butterball" theme
just a savepoint, still have lots to do and test before this really works
2024-04-27 21:06:20 -05:00
Lance Edgar
5aa8d1f9a3 Use buefy table for "find principal by perm" results
this should work for oruga as well
2024-04-27 19:17:30 -05:00
Lance Edgar
098ed5b1cf Improve keydown handling for grid Add Filter autocomplete
should work the same, but this way also works with oruga
2024-04-27 19:17:30 -05:00
Lance Edgar
890ec64f3c Misc. template cleanup per oruga effort 2024-04-27 19:17:27 -05:00
Lance Edgar
ba32422059 Fix bug when saving user preferences theme
it was being saved even when it should have been empty value
2024-04-25 23:56:21 -05:00
Lance Edgar
8b3a9c9dad Use simple field labels when possible
only use template if it must include icons etc.
2024-04-25 22:49:37 -05:00
Lance Edgar
2a22e8939c Add index title to Change Password page 2024-04-25 22:17:59 -05:00
Lance Edgar
6bee65780c Improve logic for Add Filter grid button/autocomplete
this should work for oruga as well as buefy
2024-04-25 22:00:01 -05:00
Lance Edgar
e030dc841d Expand some modal fields, per oruga styles 2024-04-25 21:31:26 -05:00
Lance Edgar
25a27af29c Use explicit flex styles instead of "level" for grid filters etc.
just to be more precise, and consistent
2024-04-25 20:45:03 -05:00
Lance Edgar
daf68cad01 Fix data type handling for datepicker and grid filter components
here is what's up now:

- <b-datepicker> expects v-model to be a Date
- <tailbone-datepicker> also expects a Date
- <grid-filter-date-value> uses String for its v-model

latter is so the value can represent a date range, e.g. 'YYYY-MM-DD|YYYY-MM-DD'

anyway there was previously confusion about data type among these
components, and hopefully they are straight now per the above outline
2024-04-25 18:52:34 -05:00
Lance Edgar
ab57fb3f0f Tweak flex styles for grid filters 2024-04-25 18:16:39 -05:00
Lance Edgar
f43259fbc1 Use proper flex styles for grid pagination footer 2024-04-25 16:04:30 -05:00
Lance Edgar
bfe6b5bc25 Use explicit flex styles for grid-tools element
and so, must ensure children of grid-tools are atomic elements
2024-04-25 15:41:06 -05:00
Lance Edgar
23e6eef604 Update changelog 2024-04-25 14:05:10 -05:00
Lance Edgar
d2aa91502a Allow deleting rows from executed batches
requires a view to explicitly opt-in.  and a separate permission is
required for the user
2024-04-25 14:02:45 -05:00
Lance Edgar
4f6ee1fb22 Use v-model to track selection etc. for download results fields 2024-04-24 22:10:56 -05:00
Lance Edgar
ddafa9ed97 Tweak icon for Download Results button
make it more portable for oruga
2024-04-24 20:19:15 -05:00
Lance Edgar
0ca3b31b2e Use normal button for grid filters
since that's more portable (for oruga) than "checkbox button"
2024-04-24 18:20:16 -05:00
Lance Edgar
9f984241c4 Cleanup grid/filters logic a bit
get rid of grids.js file, remove filter templates from complete.mako

move all that instead to filter-components.mako

for now, base template does import + setup for the latter, "just in
case" a given view has any grids.  each grid should (still) be
isolated but no code should be duplicated now.  whereas before the
grid filter templates were in comlete.mako and hence could be declared
more than once if multiple grids are on a page
2024-04-24 17:43:22 -05:00
Lance Edgar
d6fa83cd87 Fix permission checks for root user with pyramid 2.x 2024-04-19 22:27:30 -05:00
Lance Edgar
8781e34c98 Rename setting for custom user css (remove "buefy")
but have to keep support for older setting name for now
2024-04-19 21:18:57 -05:00
Lance Edgar
49da9776e7 Remove unused test fixtures 2024-04-19 20:25:07 -05:00
Lance Edgar
36b9e00dc9 Remove unused code for webhelpers2_grid 2024-04-19 20:15:44 -05:00
Lance Edgar
5cb643a32a Update changelog 2024-04-19 19:47:41 -05:00
Lance Edgar
1fa6e35663 Remove config "style" from appinfo page
there is only one style now (finally)
2024-04-19 17:45:58 -05:00
Lance Edgar
e82f0f37d8 Fix raw query to avoid SQLAlchemy 2.x warnings 2024-04-16 23:29:56 -05:00
Lance Edgar
7fa39d42e2 Fix ASGI websockets when serving on sub-path under site root 2024-04-16 23:27:50 -05:00
Lance Edgar
a95cc2b9e8 Update changelog 2024-04-16 21:14:23 -05:00
Lance Edgar
e7b8b6e818 Fix master template bug when no form in context 2024-04-16 21:13:53 -05:00
Lance Edgar
5a7deadba2 Update changelog 2024-04-16 20:11:15 -05:00
Lance Edgar
9065f42195 Fix typo when getting app instance 2024-04-16 20:10:10 -05:00
Lance Edgar
b37981e83f Prevent multi-click for grid filters "Save Defaults" button 2024-04-16 20:09:39 -05:00
Lance Edgar
0d9c5a078b Improve form support for view supplements
this seems a bit hacky yet but works for now..

cf. field logic for Vendor -> Quickbooks Bank Accounts, which requires this
2024-04-16 18:21:59 -05:00
Lance Edgar
c35c0f8b61 Update changelog 2024-04-16 10:44:33 -05:00
Lance Edgar
8b4b3de336 Add support for Pyramid 2.x; new security policy
custom apps are still free to use pyramid 1.x

new security policy is only used if config file says so
2024-04-16 09:48:29 -05:00
Lance Edgar
85d62a8e38 Reminder to improve css hack for datepicker in modal 2024-04-15 13:31:42 -05:00
Lance Edgar
52c8f3e12c Rename custom user_css context
and stop checking an older deprecated setting
2024-04-15 13:31:35 -05:00
Lance Edgar
d0d568b3a5 Escape underscore char for "contains" query filter
since underscore has special meaning for LIKE clause
2024-04-15 12:44:46 -05:00
Lance Edgar
666c16b74e Fix default dist filename for release task
not sure why this fix was needed, did setuptools behavior change?
2024-04-15 10:58:16 -05:00
Lance Edgar
2f115c0717 Update changelog 2024-04-15 10:56:49 -05:00
Lance Edgar
d4089fbc6e Some more tweaks to remove "buefy" references
mostly just docstring / comments but there were some code changes too
2024-04-14 20:56:11 -05:00
Lance Edgar
ba521abf4f Remove some references to "buefy" name within docstrings, comments 2024-04-14 20:30:52 -05:00
Lance Edgar
c036932ce4 Remove several references to "buefy" name
class methods, template filenames, etc.

also made various edits per newer conventions
2024-04-14 19:54:29 -05:00
Lance Edgar
96ba039299 Rename grids/complete template (avoid buefy name)
and rename grid methods accordingly
2024-04-13 10:13:51 -05:00
Lance Edgar
1103b09a76 Rename forms/deform template (drop buefy suffix)
for now, deprecate `form.render()` method and just use
`render_deform()` - but probably should change that to something
else eventually..?
2024-04-13 09:45:10 -05:00
Lance Edgar
cd7c1bba21 Rename template for grid filters (drop buefy suffix)
also remove some deprecated functions
2024-04-13 09:21:48 -05:00
Lance Edgar
1973614840 Rename people "view_profile" template (drop buefy suffix) 2024-04-13 09:09:23 -05:00
Lance Edgar
cbbd77c49c Show toast msg instead of silent error, when grid fetch fails
specifically, if a user clicks "Save defaults" for the grid filters,
but they aren't currently logged in, error will ensue.

this is a bit of an edge case which IIUC would require multiple tabs
etc. but still is worth avoiding an error email from it.
2024-04-11 16:58:12 -05:00
Lance Edgar
aa500351ed Avoid error for tax field when creating new department
someday should fix that for real..
2024-04-11 16:37:55 -05:00
Lance Edgar
de8751b86c Try to return JSON error when receiving API call fails
although in my testing, the error still got raised somehow in the
tweens or something?  client then sees it as a 500 response and gets
no JSON
2024-04-11 14:14:27 -05:00
Lance Edgar
a1b05540be Avoid uncaught error when updating order batch row quantities 2024-04-10 12:24:13 -05:00
Lance Edgar
e0dc858451 Update changelog 2024-04-01 18:28:39 -05:00
Lance Edgar
1889f7d269 Add basic CRUD for Person "preferred first name"
only shown if config flag says so
2024-04-01 18:26:18 -05:00
Lance Edgar
cdc857065b Update changelog 2024-03-27 13:14:23 -05:00
Lance Edgar
dfdb7a9b59 Fix bulk-delete rows for import/export batch
per changes in SQLAlchemy 1.4
2024-03-27 13:13:33 -05:00
Lance Edgar
4363b7c5d7 Update changelog 2024-03-26 12:53:20 -05:00
Lance Edgar
27fce173ce Fix how row grid values are fetched, for row proxy objects
per changes coming in SQLAlchemy 2.0
2024-03-26 11:48:52 -05:00
Lance Edgar
0b7d2f5aed Fix how metadata/bind is used for importer batch table
per changes coming in SQLAlchemy 2.0
2024-03-26 11:47:37 -05:00
Lance Edgar
25c48a97c5 Update changelog 2023-12-26 20:17:05 -06:00
Lance Edgar
a40add8f41 Expose default custorder discount for Departments 2023-12-22 11:50:05 -06:00
Lance Edgar
3bdc7175a3 Use common logic to render invoice total for receiving
and avoid error if total is none
2023-12-20 11:56:24 -06:00
Lance Edgar
90e35ee3db Hide single invoice file field for multi-invoice receiving batch 2023-12-19 12:49:33 -06:00
Lance Edgar
90630fe852 Auto-disable submit button for login form
not sure why i had explicitly disabled that before..?
2023-12-13 12:05:54 -06:00
Lance Edgar
b6618c8ee5 Update changelog 2023-12-12 11:46:28 -06:00
Lance Edgar
98fc82acfd Use ltrim(rtrim()) instead of just trim() in grid filters
apparently this is needed for older SQL Server compatibility, per
https://stackoverflow.com/questions/54340470/trim-is-not-a-recognized-built-in-function-name
2023-12-11 13:50:02 -06:00
Lance Edgar
91e7001963 Overhaul tox config for more python versions 2023-12-04 10:15:12 -06:00
Lance Edgar
d154986128 Update changelog 2023-12-01 21:57:20 -06:00
Lance Edgar
3e4bbf7092 Use clientele handler to populate customer dropdown widget 2023-12-01 19:50:07 -06:00
Lance Edgar
faeb2cb7e2 Update changelog 2023-11-30 18:25:01 -06:00
Lance Edgar
35131c8732 Provide a way to show enum display text for some version diff fields
master view must explicitly declare which enums for which fields
2023-11-30 18:23:47 -06:00
Lance Edgar
2a9d5f74ce Update changelog 2023-11-30 15:17:01 -06:00
Lance Edgar
f4cb1cb097 Avoid error when editing a department
just a temp hack, need to fix proper yet
2023-11-29 15:03:08 -06:00
Lance Edgar
e23998a88b Update changelog 2023-11-19 22:24:15 -06:00
Lance Edgar
e39581695f Fix DB picker, theme picker per Buefy conventions 2023-11-17 17:00:50 -06:00
Lance Edgar
dd9e41f651 Update changelog 2023-11-15 11:42:07 -06:00
Lance Edgar
97e7026cc9 Avoid outright error if user scans barcode for inventory count 2023-11-15 09:46:23 -06:00
Lance Edgar
853cc871f7 Remove reference to pytz library 2023-11-11 21:26:11 -06:00
Lance Edgar
fc96fb40fb Log warning instead of error for batch population error
this is most typically caused by bad user input; a warning is shown on
screen so they hopefully can guess what the problem is.  no need to
loop in the admins via email
2023-11-05 18:31:43 -06:00
Lance Edgar
172fe6c49c Update changelog 2023-11-05 17:10:32 -06:00
Lance Edgar
9fa592c5d6 Expose status code for equity payments 2023-11-05 16:57:14 -06:00
Lance Edgar
bbffe1dc82 Update changelog 2023-11-01 20:54:39 -05:00
Lance Edgar
55a115e57a Add button to confirm all costs for receiving 2023-11-01 20:53:11 -05:00
Lance Edgar
7ab3d2b635 Update changelog 2023-11-01 19:45:35 -05:00
Lance Edgar
51d7c10bc5 Fix config key for default themes list 2023-11-01 19:44:44 -05:00
Lance Edgar
b13fc99e95 Use shared logic to get batch handler 2023-11-01 19:43:46 -05:00
Lance Edgar
b231c194a4 Update changelog 2023-11-01 17:48:28 -05:00
Lance Edgar
b5da5a46de Avoid error when rendering version diff
can't always assume relationship entities are versioned
2023-11-01 17:47:07 -05:00
Lance Edgar
8522123cd3 Encode values for "between" query filter 2023-11-01 14:54:30 -05:00
Lance Edgar
bae6bc2133 Update changelog 2023-11-01 09:20:26 -05:00
Lance Edgar
2f70ce2d5c Fix missing import 2023-11-01 09:20:03 -05:00
Lance Edgar
7ac505f1f4 Update changelog 2023-11-01 08:14:09 -05:00
Lance Edgar
f47e45a928 Add deprecation warnings for ambgiguous config keys 2023-11-01 08:13:36 -05:00
Lance Edgar
a9ab59eb92 Update changelog 2023-10-30 01:06:41 -05:00
Lance Edgar
a0075f6f78 Log warning / avoid error if email profile can't be normalized
e.g. if some import error happens
2023-10-29 22:22:16 -05:00
Lance Edgar
8b07289452 Update changelog 2023-10-29 15:59:17 -05:00
Lance Edgar
c1f2f84c7f Remove unused "simple menus" module approach
now we always use a handler instead
2023-10-29 15:46:18 -05:00
Lance Edgar
da13254caa Tweak param docs for Form.set_validator() 2023-10-29 15:10:56 -05:00
Lance Edgar
fe4a178d43 Add way to "ignore" a pending product
and some related tweaks for sake of grid
2023-10-26 20:43:12 -05:00
Lance Edgar
1fc17658ff Update changelog 2023-10-26 18:44:38 -05:00
Lance Edgar
a5c1cba81b Use product lookup component for "resolve pending product" tool 2023-10-26 10:06:00 -05:00
Lance Edgar
4809cf039e Update changelog 2023-10-25 20:22:48 -05:00
Lance Edgar
a812181466 Expand the "product lookup" component to include autocomplete 2023-10-25 20:10:21 -05:00
Lance Edgar
441a6e5e0c Add separate perm for making new custorder for unknown product 2023-10-25 14:06:40 -05:00
Lance Edgar
b5c68831b5 Do not show profile buttons for inactive customer shoppers 2023-10-25 12:20:04 -05:00
Lance Edgar
cf1ef23996 Add column_only kwarg for Grid.set_label() method
pass True to affect only the column label and not the filter
2023-10-25 11:40:52 -05:00
Lance Edgar
70cc754f3e Use <b-select> for theme picker
instead of webhelpers2.html.tags.select() which seems to break for me
in dev now with python 3.10
2023-10-25 10:45:33 -05:00
Lance Edgar
72dda3771e Add price confirm prompt when adding unknown item to custorder
optional, per config
2023-10-24 19:51:27 -05:00
Lance Edgar
4247804707 Allow pending product fields to be required, for new custorder 2023-10-24 19:17:36 -05:00
Lance Edgar
e308108bf7 Show user warning if "add item to custorder" fails
specifically, if user enters alpha chars for cost/price fields
2023-10-24 17:48:08 -05:00
Lance Edgar
f708cb0b25 Fix bug when editing vendor 2023-10-24 17:44:02 -05:00
Lance Edgar
1f3877b7cb Update changelog 2023-10-24 09:54:31 -05:00
Lance Edgar
72e4daafc1 Fix config file priority for display, and batch subprocess commands 2023-10-24 09:53:40 -05:00
Lance Edgar
549976dcfb Update changelog 2023-10-24 09:27:12 -05:00
Lance Edgar
756b4b9d18 No need to configure logging
since the rattail config object will do that when first made
2023-10-23 20:35:43 -05:00
Lance Edgar
f70772fabc Allow override of version diff for master views
and misc. other tweaks
2023-10-23 15:48:48 -05:00
Lance Edgar
ec8a8d5ddc Update changelog 2023-10-23 13:06:38 -05:00
Lance Edgar
6d79766b24 Stop using sa-filters for basic grid sorting
this just breaks if we need to use "aliased" models e.g. when sorting
and/or filtering by Product "regular price" column and similar.  so
now sorting more like we always used to, except for multi-column.

nb. this still assumes callers use `Grid.make_sorter()` when declaring
the sorters.  if caller must specify more custom/explicit sort logic
then it likely will not work and we'll have to add a workaround to
allow avoiding the common logic..but that's another day
2023-10-21 16:10:36 -05:00
Lance Edgar
421266e70c Show more customer attrs for POS batch 2023-10-20 14:29:45 -05:00
Lance Edgar
d87de1dc4f Expose permission for POS batch, toggle training mode 2023-10-19 20:48:52 -05:00
Lance Edgar
dc99828b66 Show food stamp tender info for POS batch 2023-10-19 19:12:28 -05:00
Lance Edgar
5e8ea67773 Include invoice number for receiving batch row API 2023-10-19 14:57:06 -05:00
Lance Edgar
0d30247353 Add validtion to prevent duplicate files for multi-invoice receiving
by comparing sha256 hash values for each file
2023-10-19 14:03:25 -05:00
Lance Edgar
aaf6f05820 Remove sorter for "Credits?" column in purchasing batch row grid
too convoluted, and broken per recent sort overhaul
2023-10-19 13:02:17 -05:00
Lance Edgar
954a2b78be Expose new price fields for POS batch row 2023-10-18 21:25:32 -05:00
Lance Edgar
230a54cb99 Fix default grid filter when "local" date times are involved 2023-10-18 21:25:13 -05:00
Lance Edgar
13565d1c45 Avoid "None" when rendering product UOM field 2023-10-18 21:24:37 -05:00
Lance Edgar
919d8d109f Use Grid.make_sorter() instead of legacy code
b/c multi-column sorting relies on this
2023-10-18 18:18:55 -05:00
Lance Edgar
659f5a8fe1 Replace dropdowns with autocomplete, for "find principals by perm" 2023-10-18 17:35:14 -05:00
Lance Edgar
f86cc83996 Fix order xlsx download if missing order date 2023-10-17 15:26:22 -05:00
Lance Edgar
7525aaaa87 Expose more permissions for POS 2023-10-12 16:31:44 -05:00
Lance Edgar
115e95b9a8 Update changelog 2023-10-12 10:37:12 -05:00
Lance Edgar
5940778189 Fix version child classes for Customers view
must be sure to include any supplements
2023-10-12 10:33:56 -05:00
Lance Edgar
1a15d70568 Add some awareness of suspend/resume for POS batch 2023-10-11 23:11:23 -05:00
Lance Edgar
d66dd5f199 Add permission for testing error handling at POS 2023-10-11 19:55:51 -05:00
Lance Edgar
507a9ffc71 Expose department tax, FS flag 2023-10-11 18:35:35 -05:00
Lance Edgar
cd82f8927b Fix grid sorting when column key/name differ 2023-10-11 16:13:20 -05:00
Lance Edgar
cddec51582 Update changelog 2023-10-11 15:56:16 -05:00
Lance Edgar
78deb5d09a Use autocomplete instead of dropdown for grid "add filter" 2023-10-10 22:10:01 -05:00
Lance Edgar
4328b9e385 Show full version history within the "view" page
avoid full page loads when navigating version history
2023-10-10 11:02:02 -05:00
Lance Edgar
44112a3a4b Allow null for FalafelDateTime form fields 2023-10-09 15:50:41 -05:00
Lance Edgar
9efe767654 Add smarts to show display text for some version diff fields
e.g. show `str(customer)` along with `customer_uuid` since almost
nobody will "care" about the uuid so much, they just want the name
2023-10-09 00:19:29 -05:00
Lance Edgar
edb5393cdc Add front-end support for multi-column grid sorting
user must ctrl-click column header to engage multi-sort
2023-10-08 16:38:24 -05:00
Lance Edgar
6d7754cf2a Add back-end support for multi-column grid sorting
or very nearly, anyway.  front-end still just supports 1 column yet
2023-10-08 14:29:01 -05:00
Lance Edgar
4beca7af20 Make grid JS loadAsyncData() method truly async
not sure what this does but it seems to work, we'll see
2023-10-07 20:13:41 -05:00
Lance Edgar
a201072a9d Update changelog 2023-10-07 18:57:03 -05:00
Lance Edgar
07b1d0841e Improve views for taxes, esp. in POS batches 2023-10-07 16:26:33 -05:00
Lance Edgar
eccb855d09 Expose tender ref in POS batch rows; new tender flags 2023-10-06 20:34:14 -05:00
Lance Edgar
2f4877a264 Add "mark complete" button for inventory batch row entry page 2023-10-06 15:53:17 -05:00
Lance Edgar
d84b98041f Avoid deprecated logic for fetching vendor contact email/phone 2023-10-06 15:03:17 -05:00
Lance Edgar
2ae2cdc4bd Update changelog 2023-10-06 10:13:18 -05:00
Lance Edgar
d1d781966f Fix bug for param helptext in New Report page 2023-10-06 10:12:38 -05:00
Lance Edgar
53cf771c81 Update changelog 2023-10-06 10:00:37 -05:00
Lance Edgar
d45ee34b0c Expose permissions for POS, if so configured 2023-10-06 09:16:25 -05:00
Lance Edgar
e1a64de205 Fix bug in POS batch view 2023-10-05 19:59:57 -05:00
Lance Edgar
b30f6cdf3a Fix CRUD pages for tempmon clients, probes
for some reason if helptext had embedded newlines, it would now fail
to render the form altogether.  guess that is a result of recent
change to e.g. `<b-field :message="['foo', 'bar']">` logic,
somehow.. anyway hopefully this fixes and no more surprises
2023-10-05 13:11:05 -05:00
Lance Edgar
3dfab8e42d Update changelog 2023-10-04 13:56:22 -05:00
Lance Edgar
7bae01f03c Improve master view oneoff_import() method
be more flexible about what caller must provide
2023-10-04 13:07:26 -05:00
Lance Edgar
f3dddf0e40 Avoid deprecated pretty_hours() function 2023-10-04 11:56:50 -05:00
Lance Edgar
0b7791070f Update changelog 2023-10-04 10:59:54 -05:00
Lance Edgar
4125be7e8d Re-work FalafelDateTime logic a bit
need to be more "standard" in how (de)serialize works etc.

also be sure to show error messages if present, not just field helptext
2023-10-02 09:54:34 -05:00
Lance Edgar
746e13d134 Expose cash-back flags for tenders 2023-10-01 18:54:56 -05:00
Lance Edgar
b7ccc6ea07 Use enum to display POS_ROW_TYPE 2023-10-01 17:31:33 -05:00
Lance Edgar
a6bc3fb793 Update changelog 2023-10-01 12:09:32 -05:00
Lance Edgar
9f7e70f240 Add support for void rows in POS batch 2023-09-30 21:08:01 -05:00
Lance Edgar
0ee6725188 Tidy up logic for vendor filtering in products grid
was hoping to "fix" count issue but alas..

refs #23
2023-09-28 10:56:15 -05:00
Lance Edgar
f572757f00 Expose views for tenders, more columns for POS batch/rows 2023-09-27 17:13:49 -05:00
Lance Edgar
abcf1e1895 Add clone support for POS batches
just for testing of course..
2023-09-26 17:52:17 -05:00
Lance Edgar
a9e9474f5c Do not allow executing custorder if no customer is set
or really any reason, as defined by handler
2023-09-26 09:32:57 -05:00
Lance Edgar
a11be5a1e1 Update changelog 2023-09-25 19:41:59 -05:00
Lance Edgar
e23b2f8711 Add custom form type/widget for time fields
ugh this still isn't that great, but making progress overall
2023-09-25 19:22:02 -05:00
Lance Edgar
032d37194f Update changelog 2023-09-25 18:06:16 -05:00
Lance Edgar
3e56950872 Expose POS batch views as "typical" 2023-09-24 19:30:59 -05:00
Lance Edgar
5a2612acab Update changelog 2023-09-24 14:47:54 -05:00
Lance Edgar
bda05aed86 Use header button instead of link for "touch" instance 2023-09-24 08:37:50 -05:00
Lance Edgar
91ac1a9031 Show customer for POS batches 2023-09-23 20:08:40 -05:00
Lance Edgar
53e8c15267 Add basic views for POS batches 2023-09-23 11:14:43 -05:00
Lance Edgar
d329b2945c Show "true" (calculated) equity total in members grid
pretty sure will need to tweak this but wanted something in place at least
2023-09-21 14:39:18 -05:00
Lance Edgar
abca0115a6 Add remove_sorter() method for grids 2023-09-21 14:37:33 -05:00
Lance Edgar
3d6cc8a490 Show yesterday by default for Trainwreck if so configured 2023-09-20 18:13:52 -05:00
Lance Edgar
836fc0bf5b Update changelog 2023-09-19 16:37:05 -05:00
Lance Edgar
510b8383a4 Show catalog/invoice costs as 2-decimal currency in receiving 2023-09-19 15:03:16 -05:00
Lance Edgar
8b15f1304f Use small text input for receiving cost editor fields 2023-09-19 14:45:48 -05:00
Lance Edgar
6274e33a8c Prevent catalog/invoice cost edits if receiving batch is complete 2023-09-19 14:41:15 -05:00
Lance Edgar
1f97d4f5e5 Add link to vendor name for receiving batches grid 2023-09-19 14:40:58 -05:00
Lance Edgar
b566549d15 Update changelog 2023-09-18 18:40:51 -05:00
Lance Edgar
4d8c8b199c Fix bug for new receiving from scratch via API 2023-09-18 18:37:41 -05:00
Lance Edgar
d1d69e9488 Show user warning if receive quick lookup fails
just b/c a UPC doesn't exist yet doesn't prevent the batch from (in
some cases) adding a row for "unknown product" - but if the UPC is
sufficiently invalid, that can't happen
2023-09-18 18:28:11 -05:00
Lance Edgar
a01fd62899 Update changelog 2023-09-17 21:21:10 -05:00
Lance Edgar
70956a2c47 Tweaks to improve handling of "missing" items for receiving 2023-09-17 18:30:38 -05:00
Lance Edgar
e894d1d1f4 Include PO number for receiving batch details via API 2023-09-17 18:03:30 -05:00
Lance Edgar
cc7b9ccb86 Avoid error when history has blanks for ordering worksheet 2023-09-17 17:23:59 -05:00
Lance Edgar
a807a0f50c Add "falafel" custom date/time field type and widget
finally able to edit datetime fields, but feels like a lot of
assumptions to make, just to determine time zone..so keeping naive UTC
on the backend still, and naive local on the frontend

in general this needs more polish, but is a start..
2023-09-16 20:01:32 -05:00
Lance Edgar
99065548ff Update changelog 2023-09-16 13:06:54 -05:00
Lance Edgar
df897aef13 Make member key field readonly when viewing equity payment 2023-09-16 13:06:26 -05:00
Lance Edgar
1cfc275eae Update changelog 2023-09-15 19:30:27 -05:00
Lance Edgar
3968e40a0b Add basic feature for "grid totals" 2023-09-15 19:19:20 -05:00
Lance Edgar
ac6106ca69 Update changelog 2023-09-15 10:34:25 -05:00
Lance Edgar
eed73eca81 Add get_rattail_app() method for view supplements 2023-09-14 12:56:15 -05:00
Lance Edgar
608da824d9 Tweak default field list for batch views 2023-09-13 13:14:00 -05:00
Lance Edgar
03fc301dec Update changelog 2023-09-12 18:31:18 -05:00
Lance Edgar
1cad8b2481 Show events instead of notes, in field subgrid for custorder item 2023-09-12 12:39:23 -05:00
Lance Edgar
e930199f83 Avoid legacy logic for Customer.people schema 2023-09-11 17:13:07 -05:00
Lance Edgar
60044d5cdf Update changelog 2023-09-11 15:58:35 -05:00
Lance Edgar
e793ba6630 Improve grids for custorder items
main grid as well as rows grid for Pending Product
2023-09-11 15:24:00 -05:00
Lance Edgar
67ec6f7773 Add support for "mark received" when viewing custorder item 2023-09-10 19:55:48 -05:00
Lance Edgar
ddb8e3656f Add support for toggling custorder item "flagged" 2023-09-10 17:49:29 -05:00
Lance Edgar
e49e0edc57 Misc. improvements for Customer Orders view 2023-09-10 17:34:54 -05:00
Lance Edgar
e255c35e86 Set stacklevel for all deprecation warnings 2023-09-10 13:51:11 -05:00
Lance Edgar
48daa042d1 Show related customer orders for Pending Product view
and similar tweaks
2023-09-10 09:34:56 -05:00
Lance Edgar
64c58a3cf8 Optionally configure SQLAlchemy Session with future=True
this avoids the need for setting `cascade_backrefs=False` everywhere

https://docs.sqlalchemy.org/en/14/errors.html#error-s9r1

https://docs.sqlalchemy.org/en/14/orm/session_api.html#sqlalchemy.orm.Session.params.future
2023-09-10 07:44:13 -05:00
Lance Edgar
a9fbf48053 Use common POST logic for submitting new customer order 2023-09-09 16:21:57 -05:00
Lance Edgar
ccb4661b39 Add custom hook for grid "apply filters"
so a page can know when the data set changes..

this seems a bit hacky, may need a better solution some day
2023-09-09 14:14:23 -05:00
Lance Edgar
c5344d2df6 Update changelog 2023-09-08 19:55:14 -05:00
Lance Edgar
669e50e406 Fix member key display for equity payment form 2023-09-08 19:53:10 -05:00
Lance Edgar
7221400b88 Fix msg body display, download link for email bounces 2023-09-08 10:56:25 -05:00
Lance Edgar
6e50288bd4 Add grid link for equity payment description 2023-09-08 08:49:43 -05:00
Lance Edgar
84de5e09a2 Update changelog 2023-09-07 21:00:40 -05:00
Lance Edgar
f717bc47e5 Fallback to None when getting values for merge preview 2023-09-07 20:57:33 -05:00
Lance Edgar
f732e04f49 Update changelog 2023-09-07 18:36:02 -05:00
Lance Edgar
ecf46fa6fe Improve display for member equity payments 2023-09-07 18:19:46 -05:00
Lance Edgar
b1ec1b8817 Update changelog 2023-09-02 13:56:10 -05:00
Lance Edgar
bd7e6f9f8a Tweaks for cost editing within a receiving batch
never show PO Cost column in row grid, since Invoice Cost is what
receiving is most concerned with

add "zig-zag" entry behavior when both catalog and invoice costs are editable
2023-09-02 11:39:49 -05:00
Lance Edgar
75caface6b Add products API route to fetch label profiles for use w/ printing 2023-09-02 10:56:06 -05:00
Lance Edgar
de373a683b Add grid filter type for BigInteger columns
so we can filter by larger values
2023-09-01 11:20:30 -05:00
Lance Edgar
5ab47aeead Update changelog 2023-08-31 10:08:20 -05:00
Lance Edgar
62aa0c5965 Preserve URL hash when redirecting in grid "reset to defaults" 2023-08-30 23:51:18 -05:00
Lance Edgar
625982d639 Avoid deprecated User.email_address property 2023-08-30 23:32:09 -05:00
Lance Edgar
9f65de2ba6 Update changelog 2023-08-30 22:08:50 -05:00
Lance Edgar
f4267737c3 Let "new product" batch override type-2 UPC lookup behavior 2023-08-30 20:10:10 -05:00
Lance Edgar
74678882ee Update changelog 2023-08-29 22:21:20 -05:00
Lance Edgar
4e2125d613 Add support for "missing" credit in mobile receiving 2023-08-29 16:10:14 -05:00
Lance Edgar
12e4779093 Fairly massive overhaul of the Profile view; standardize tabs etc.
much cleaner and more consistent interface now, between the main
ProfileInfo component, and various *Tab components

also cleaner interface between client-side JS and server view methods

to my knowledge this is complete and breaks nothing..we'll see!
2023-08-28 20:43:31 -05:00
Lance Edgar
844c629a6a Fix profile history to show when a CustomerShopperHistory is deleted 2023-08-25 13:59:58 -05:00
Lance Edgar
a40b44b6e3 Fix profile history to show when a CustomerShopperHistory is deleted 2023-08-25 10:41:20 -05:00
Lance Edgar
bc8b5a8d32 Link to product record, for New Product batch row
also fix a typo
2023-08-25 09:08:33 -05:00
Lance Edgar
8be7dac33b Include shopper history from parent customer account perspective
..right?  or should this be hidden? configurable etc.?
2023-08-24 22:00:11 -05:00
Lance Edgar
b2aea57da6 Auto-select text when editing costs for receiving 2023-08-18 15:04:52 -05:00
Lance Edgar
a007606863 Declare "from PO" receiving workflow if applicable, in API 2023-08-17 18:12:42 -05:00
Lance Edgar
90075b3b65 When bulk-deleting, skip objects which are not "deletable"
whatever that means in context
2023-08-09 18:04:51 -05:00
Lance Edgar
4ecea891b3 Update changelog 2023-08-08 18:42:50 -05:00
Lance Edgar
845b5cda1a Fix custom cell click handlers in main buefy grid tables
just used for editing catalog/invoice cost in receiving thus far..
2023-08-08 18:06:22 -05:00
Lance Edgar
f2915afda4 Fix HTML rendering for UOM choice options
also avoid deprecated config methods
2023-08-08 14:11:54 -05:00
Lance Edgar
d504da19c5 Add common logic to validate employee reference field 2023-08-07 12:36:07 -05:00
Lance Edgar
ec7b0cdda1 Update changelog 2023-08-03 22:42:34 -05:00
Lance Edgar
9f0cfc68c1 Make system key searchable for problem report grid 2023-08-02 21:59:52 -05:00
Lance Edgar
1f3b5a49c4 Update changelog 2023-07-15 19:32:04 -05:00
Lance Edgar
a84bcf688b Tweak display options for tempmon probe readings graph 2023-07-07 17:56:45 -05:00
Lance Edgar
4729785b05 Show invoice number for each row in receiving 2023-07-07 17:19:08 -05:00
Lance Edgar
6b6e358dbe Update changelog 2023-07-07 15:38:08 -05:00
Lance Edgar
58f9b3ce2a Optimize "auto-receive" batch process
disable versioning when doing "auto-receive" for a receiving batch
2023-07-06 21:23:44 -05:00
Lance Edgar
8742a03e18 Update changelog 2023-07-03 09:52:42 -05:00
Lance Edgar
1be26b7f33 Allow "arbitrary" PO attachment to purchase batch
for sake of other POS integration etc.
2023-06-27 18:18:35 -05:00
Lance Edgar
08a75f6e9f Avoid deprecated product key field getter 2023-06-27 12:37:00 -05:00
Lance Edgar
8cc6def93e Update changelog 2023-06-20 17:06:54 -05:00
Lance Edgar
70ee784818 Include user "active" flag in profile view context
whoops, missed that one..
2023-06-20 17:06:20 -05:00
Lance Edgar
8932b51216 Update changelog 2023-06-20 11:54:09 -05:00
Lance Edgar
69bda79baf Turn on quickie person search for CustomerShopper views
also set default sort for that grid
2023-06-18 21:20:45 -05:00
Lance Edgar
214f3d9b1e Improve merge support for records with no uuid
for now we "pretend" they have a uuid still, custom view is
responsible for determining the value for each row if needed
2023-06-18 21:20:45 -05:00
Lance Edgar
58354e7adf Add views etc. for member equity payments 2023-06-18 21:20:40 -05:00
Lance Edgar
aa5e44efb5 Update changelog 2023-06-17 18:12:30 -05:00
Lance Edgar
9572fbf584 Fix some things for viewing a member 2023-06-17 16:56:40 -05:00
Lance Edgar
b6cb119e89 Remove unwanted revisions for CustomerPerson etc. 2023-06-17 16:50:39 -05:00
Lance Edgar
12eeb5df97 Add basic support for Person quickie lookup
shows profile view if person is found
2023-06-17 16:09:24 -05:00
Lance Edgar
d77de76c97 Add support for Notes tab in profile view 2023-06-17 14:24:08 -05:00
Lance Edgar
105dab7a3d Tweak SimpleRequestMixin to not rely on response.data.ok
instead just assume ok unless `response.data.error` is set
2023-06-17 14:13:37 -05:00
Lance Edgar
ba2b4bf12c Cleanup some wording in profile view template 2023-06-17 02:27:17 -05:00
Lance Edgar
b1489c56e2 Add basic Shopper tab for profile view 2023-06-17 02:22:18 -05:00
Lance Edgar
c601d46970 Update changelog 2023-06-16 22:22:03 -05:00
Lance Edgar
51cad13f5a Update usage of app handler per upstream changes 2023-06-16 22:15:52 -05:00
Lance Edgar
17ae06f9c1 Update changelog 2023-06-16 20:43:00 -05:00
Lance Edgar
5a03f5c23e Join the Person model for Customers grid differently based on config 2023-06-16 20:08:27 -05:00
Lance Edgar
bf1726a52b Add users context data for profile view
instead of using server-side data/logic for users tab
2023-06-16 17:04:39 -05:00
Lance Edgar
c1f72e0d11 Fix grid filter bug when switching from 'equal' to 'between' verbs
and vice versa
2023-06-16 12:21:51 -05:00
Lance Edgar
c2227b306b Update changelog 2023-06-15 10:47:38 -05:00
Lance Edgar
961cf803f2 Prefer account holder, shoppers over legacy Customers.people
but until all are migrated, support both
2023-06-14 23:33:05 -05:00
Lance Edgar
eab3b75ae5 Update changelog 2023-06-12 20:35:00 -05:00
Lance Edgar
92538b87ad Add master view for CustomerShopper 2023-06-11 20:52:24 -05:00
Lance Edgar
5f4d393db3 Change label for Member.person to "Account Holder"
probably should rename table column etc. too but that can wait
2023-06-11 15:42:14 -05:00
Lance Edgar
edd5d49e36 Improve shoppers/people display for Customer tab in profile view
also expose settings for people/clientele handlers
2023-06-11 14:52:07 -05:00
Lance Edgar
0d52d554e7 Add options for grid results to link straight to Profile view
probably should have done this a long time ago...
2023-06-10 23:19:52 -05:00
Lance Edgar
f1a8b8df7f Include version history for CustomerShopper, in profile view 2023-06-10 21:09:35 -05:00
Lance Edgar
9e1b83cbbe Let external customer link buttons be more dynamic, for profile view
need to copy this pattern elsewhere yet i'm sure..
2023-06-10 20:12:33 -05:00
Lance Edgar
40ae14bd7a Consider vendor catalog batch views "typical" 2023-06-10 18:59:53 -05:00
Lance Edgar
e2b91dca23 Move "view history" and related buttons, for person profile view
need those to be more front-and-center
2023-06-10 14:22:21 -05:00
Lance Edgar
3fde80f991 Add basic support for exposing Customer.shoppers
now there is a Shoppers field when viewing a Customer, unless
configured otherwise

also tweaked some logic for navigating Customer/Person relationships,
to handle implications of Shoppers being (maybe) present
2023-06-07 20:57:30 -05:00
Lance Edgar
afd5c3a5fd Update changelog 2023-06-06 19:29:47 -05:00
Lance Edgar
cfdb492349 Add support for version history in person profile view
yay, finally
2023-06-06 16:37:58 -05:00
Lance Edgar
816e652357 Add basic support for membership types 2023-06-06 13:13:19 -05:00
Lance Edgar
027d44e04a Remove old/unused feedback templates 2023-06-06 11:57:20 -05:00
Lance Edgar
c38dc8b842 Use *actual* current URL for user feedback msg
was using current URL as of page load, but #hash can change after
that, e.g. on profile view
2023-06-06 11:54:58 -05:00
Lance Edgar
0d97ff2936 Add support for "configured customer/member key"
also improve product key support, same patterns
2023-06-06 11:36:19 -05:00
Lance Edgar
9b59b44609 Add "touch" support for Members 2023-06-06 09:40:14 -05:00
Lance Edgar
6f02e1b18e Tweak logic for MasterView.get_action_route_kwargs()
hopefully this improves default handling when model keys are
composite, and if we can confirm the "secondary" (previous) logic no
longer happens, then can remove that altogether..?
2023-06-06 09:39:02 -05:00
Lance Edgar
488126b92c Add customer number filter for People grid 2023-06-05 20:18:57 -05:00
Lance Edgar
4318f03bd6 Add "typical" view config, for e.g. Theo and the like
bring in all normal views for backoffice retail
2023-06-05 20:18:40 -05:00
Lance Edgar
13ac33bb27 Update changelog 2023-06-02 14:19:53 -05:00
Lance Edgar
93b03c9562 Expose mail handler and template paths in email config page 2023-06-02 14:14:33 -05:00
Lance Edgar
e1685231c2 Update changelog 2023-06-01 12:17:19 -05:00
Lance Edgar
90cb25446b Fix datasync consumer setting save logic 2023-06-01 11:37:26 -05:00
Lance Edgar
fd2b290fd0 Save datasync config with new keys, per RattailConfiguration 2023-06-01 11:12:31 -05:00
Lance Edgar
b4816c6289 Share some code for validating vendor field
and add validation for new Ordering batch
2023-05-30 13:25:20 -05:00
Lance Edgar
0d9a502801 Fix test for config object 2023-05-25 14:55:41 -05:00
Lance Edgar
b840ae7513 Update changelog 2023-05-25 12:21:04 -05:00
Lance Edgar
29767dfcfb Define essential views for API 2023-05-19 19:46:18 -05:00
Lance Edgar
dd3f91cf0c Tweak byjove project generator form 2023-05-19 19:45:41 -05:00
Lance Edgar
ae38e09d1b Avoid error when filter params not valid 2023-05-19 17:43:31 -05:00
Lance Edgar
de13e48aa5 Expose basic way to send test email
most of the mechanics of sending email could already be tested by
sending a "preview" email of any type, or e.g. via Feedback.  but it
seemed like the Configure Email Settings page should have a dedicated
way to test sending
2023-05-19 17:16:19 -05:00
Lance Edgar
05bb3849a2 Prevent bug in upgrade diff for empty new version
apparently this is quite the rare case..but can happen
2023-05-18 19:57:05 -05:00
Lance Edgar
af405cfd10 Update changelog 2023-05-18 13:51:59 -05:00
Lance Edgar
8d880fc9dd Add workaround for "share grid link" on insecure sites 2023-05-18 13:48:22 -05:00
Lance Edgar
c18367739f Add initial swagger.json endpoint for API
probably this needs more, but good enough to test with
2023-05-16 23:34:48 -05:00
Lance Edgar
26a6a4d991 Update changelog 2023-05-16 17:33:55 -05:00
Lance Edgar
93bce57888 Prevent error in old product search logic
when no POD image URL is configured
2023-05-16 17:33:07 -05:00
Lance Edgar
5f6b389556 Replace setup.py contents with setup.cfg 2023-05-16 15:02:39 -05:00
Lance Edgar
d90cab40a6 Update changelog 2023-05-15 08:49:01 -05:00
Lance Edgar
c002d3d182 Add basic support for managing, and accepting API tokens
also various other changes in pursuit of that.  so far tokens are only
accepted by web API and not traditional web app
2023-05-15 08:10:42 -05:00
Lance Edgar
85947878c4 Get rid of newstyle flag for Form.validate() method
we always/only use "new style" now
2023-05-15 08:10:42 -05:00
Lance Edgar
a991dc0684 Update changelog 2023-05-13 16:57:36 -05:00
Lance Edgar
29817653ed Warn user if DB not up to date, in new table wizard
also start adding 'dirty' page behavior, to warn user if navigating
away that changes will be lost

also improve steps in wizard, so page header is scrolled into view
when prev/next buttons are clicked.  unfortunately it still does not
work right if user clicks the step number on left of screen..
2023-05-12 21:27:15 -05:00
Lance Edgar
f5f973dc3a Tweak button wording in "find role by perm" form 2023-05-12 19:21:48 -05:00
Lance Edgar
f49b4d1b8b Update changelog 2023-05-10 20:20:30 -05:00
Lance Edgar
82656f263d Move row delete check logic for receiving to batch handler 2023-05-10 18:47:11 -05:00
Lance Edgar
f942716bf9 Update changelog 2023-05-09 20:31:43 -05:00
Lance Edgar
dcc7819466 Misc. tweaks for "run import job" form 2023-05-09 20:25:05 -05:00
Lance Edgar
8fcef1fb4d Add form config for generating 'shopfoo' projects 2023-05-09 15:30:23 -05:00
Lance Edgar
2f5e01c9e9 Update changelog 2023-05-05 19:10:54 -05:00
Lance Edgar
50d1bbbe4d Add "rattail-adjacent" logic for generating projects 2023-05-05 13:30:32 -05:00
Lance Edgar
62bdf82627 Include project views by default, in "essential" views 2023-05-05 10:39:29 -05:00
Lance Edgar
2ed63b1c1a Massive overhaul of "generate project" feature
previous incarnation was woefully lacking.  new feature is much more
extensible.  still need to remove old POS integration specifics in
some places.

and a couple of unrelated things that snuck in..

- deprecate `rattail.util.OrderedDict`
- deprecate `rattail.util.import_module_path()`
- deprecate `rattail.util.import_reload()`
2023-05-05 00:18:16 -05:00
Lance Edgar
026d98551c Update changelog 2023-05-03 10:55:15 -05:00
Lance Edgar
f913ed8332 Expose, honor the prevent_password_change flag for Users 2023-05-02 19:13:28 -05:00
Lance Edgar
2863ff7a5c Remove references to deprecated extra in tox.ini 2023-04-27 09:22:48 -05:00
Lance Edgar
4993b349ef Avoid error if tempmon probe has invalid status 2023-04-21 12:04:36 -05:00
Lance Edgar
eb31fa9ab7 Update changelog 2023-04-17 16:10:37 -05:00
Lance Edgar
18f8577005 Improve global menu search behavior for multiple terms 2023-03-31 14:02:09 -05:00
Lance Edgar
6ab3898f27 Allow bulk-delete for products grid 2023-03-31 12:55:05 -05:00
Lance Edgar
efb8f8f315 Update changelog 2023-03-27 12:53:16 -05:00
Lance Edgar
e96f8844e2 Overhaul the "find by perm" feature a bit
use GET instead of POST on form submit, so can more easily share URL
for a particular result

also get rid of WTForms dependency!  sheesh

results table is still not pretty but..feeling lazy
2023-03-25 13:03:47 -05:00
Lance Edgar
45b8d9fb84 Fix table sorting for FK reference column in new table wizard
also add LargeBinary data type option
2023-03-25 11:34:30 -05:00
Lance Edgar
2f8411ba2f Add has_perm() etc. to request during the NewRequest event
still get the occasional server error when handling what should be a
simple 404 request e.g. for /wp-login.php

error indicates there is no `request.has_perm()` at the time, so
hoping this moves it earlier in the life cycle so it *will* exist..
2023-03-25 01:03:49 -05:00
Lance Edgar
714c0a6cfd Avoid accidental auto-submit of new msg form, for subject field 2023-03-23 10:23:19 -05:00
Lance Edgar
9125d7ef74 Update changelog 2023-03-15 09:43:21 -05:00
Lance Edgar
1ce67953df Let providers do DB connection setup for web API 2023-03-15 09:33:20 -05:00
Lance Edgar
e19adf8907 Remove version workaround for sphinx
no longer needed
2023-03-09 15:26:34 -06:00
Lance Edgar
9ee46107d2 Update changelog 2023-03-09 14:10:31 -06:00
Lance Edgar
2ebe0401c3 Fix JSON rendering for Cornice API views
also make sure we use Cornice for all API views
2023-03-09 14:07:10 -06:00
Lance Edgar
5aa982c95f Update changelog 2023-03-08 20:39:39 -06:00
Lance Edgar
46c7ef42de Remove version cap for cornice, now that we require python3 2023-03-08 20:38:16 -06:00
Lance Edgar
a9c4d37819 Update changelog 2023-03-02 11:05:20 -06:00
Lance Edgar
e8f235e4f7 Allow download results for Trainwreck
just basic transaction headers so far..
2023-02-28 15:05:38 -06:00
Lance Edgar
ad311e9e7e Add "equal to any of" verb for string-type grid filters 2023-02-28 14:30:25 -06:00
Lance Edgar
01af73502a Update changelog 2023-02-24 20:04:14 -06:00
Lance Edgar
a81e121ffd Allow sort/filter by vendor for sample files grid 2023-02-22 22:41:12 -06:00
Lance Edgar
cf7e3c2302 Update changelog 2023-02-22 22:00:36 -06:00
Lance Edgar
743a2ccd07 Add views for sample vendor files 2023-02-22 22:00:05 -06:00
Lance Edgar
e77650c997 Update changelog 2023-02-21 19:14:19 -06:00
Lance Edgar
d1fc5d5c38 Validate vendor for catalog batch upload 2023-02-21 17:35:47 -06:00
Lance Edgar
2fa62acbbd Update changelog 2023-02-20 21:50:44 -06:00
Lance Edgar
ad4ec41e15 Make config param more explicit, for GridFilter constructor
i.e. the rattail config object
2023-02-14 17:32:04 -06:00
Lance Edgar
539f4a5c31 Update changelog 2023-02-14 16:07:23 -06:00
Lance Edgar
7b2faf90f2 Add dedicated view config methods for "view" and "edit help"
so they can be invoked explicitly from elsewhere, keeping same logic

cf. Catapult Worksheets
2023-02-13 20:29:59 -06:00
Lance Edgar
ac57ddbb16 Update changelog 2023-02-12 10:04:27 -06:00
Lance Edgar
b434fa108d More refactoring, Query.get() => Session.get() 2023-02-12 09:34:38 -06:00
Lance Edgar
f1496c771e Stop running tests for python 3.5; do run for 3.6, 3.9 2023-02-12 09:29:30 -06:00
Lance Edgar
f611a5a521 Refactor Query.get() => Session.get() per SQLAlchemy 1.4 2023-02-11 22:05:45 -06:00
Lance Edgar
81aa0ae109 Update changelog 2023-02-11 11:55:43 -06:00
Lance Edgar
5736faf24c Use sa-filters instead of sqlalchemy-filters for API queries
latter was abandoned it seems; former has support for SQLAlchemy 1.4
and looks to be a drop-in replacement

another option, if needed at some point, though i like the looks of it
less, is https://sqlalchemy-filters-plus.readthedocs.io/

see also:

https://github.com/juliotrigo/sqlalchemy-filters/pull/69
https://github.com/juliotrigo/sqlalchemy-filters/issues/72
2023-02-11 11:53:47 -06:00
Lance Edgar
c87c50bfb9 Update changelog 2023-02-11 09:59:45 -06:00
Lance Edgar
10162b378a Remove legacy grid for alt codes in product view
whoops missed this in jquery purge
2023-02-10 21:23:57 -06:00
Lance Edgar
e879102768 Remove python2 stuff from tox.ini 2023-02-10 20:42:36 -06:00
Lance Edgar
de4667cc71 Update changelog 2023-02-10 20:25:02 -06:00
Lance Edgar
8fc3a71e0f Fix multi-file upload widget bug
happened when only one file was being uploaded
2023-02-10 12:40:23 -06:00
Lance Edgar
2d2c94e4d7 Expose setting for POD image URL 2023-02-10 12:21:55 -06:00
Lance Edgar
21669b5f4a Remove legacy vendor sources grid for product view
whoops, missed that when purging jquery theme
2023-02-10 11:39:10 -06:00
Lance Edgar
ad5dec3dc6 Use label handler to avoid deprecated logic 2023-02-08 20:19:15 -06:00
Lance Edgar
32fc0415da Fix auto-advance on ENTER for login form
if user hits ENTER while focused on username field, just set focus to
password field but do not submit form.  if user hits ENTER on while
the password field is focused, then submit form

this has long been the behavior but it was broken when removing jquery
2023-02-07 16:13:07 -06:00
Lance Edgar
5f70a524e9 Use latest zope.sqlalchemy package
session / transaction registration modified per upstream changes, but
previous logic kept to support older versions of zope.sqlalchemy - for
now, although probably should require minimum version soon?
2023-02-07 12:20:22 -06:00
Lance Edgar
9263dd4ddb Add dependency for pyramid_retry 2023-02-04 19:03:05 -06:00
Lance Edgar
f17ff59ba6 Update changelog 2023-02-03 19:52:26 -06:00
Lance Edgar
15fb7f45b8 Fix auto-focus username for login form 2023-02-03 19:51:50 -06:00
Lance Edgar
f71eadd409 Update changelog 2023-02-03 18:07:50 -06:00
Lance Edgar
49122d940d Stop including deform JS static files
although maybe we *should* be using that method, for some things?  can
revisit later if desired
2023-02-03 18:06:40 -06:00
Lance Edgar
eb1351d108 Update changelog 2023-02-03 17:39:28 -06:00
Lance Edgar
b67df1328b Remove liburl logic, config for jquery 2023-02-03 17:32:39 -06:00
Lance Edgar
976a5836a9 Purge even more jquery stuff
and related static files etc. from old themes

this might be the end of it..??
2023-02-03 17:08:33 -06:00
Lance Edgar
2ebae17839 Refactor the Ordering Worksheet generator, per Buefy 2023-02-03 16:10:08 -06:00
Lance Edgar
eddbfcab36 Allow editing the Department field for a Subdepartment 2023-02-03 16:10:08 -06:00
Lance Edgar
320aaab4b3 Replace 'default' theme to match 'falafel'
falafel is now an empty wrapper around default

hell yeah
2023-02-03 16:10:08 -06:00
Lance Edgar
f0880785a9 Add new Buefy-specific upgrade template
since that was broken..
2023-02-03 16:10:08 -06:00
Lance Edgar
9faaea881d Remove all deprecated use_buefy logic
also remove some static files no longer used, etc.
2023-02-03 16:10:04 -06:00
Lance Edgar
01e5eee981 Officially drop support for python2 2023-02-02 23:21:45 -06:00
Lance Edgar
94a0a57cfe Update changelog 2023-02-02 22:45:58 -06:00
Lance Edgar
265c7ad76f Always assume use_buefy=True within main page template
so can start removing from context for various views
2023-02-02 21:18:00 -06:00
Lance Edgar
36a902398a Update changelog 2023-02-02 20:24:19 -06:00
Lance Edgar
506de0383f Form constructor assumes use_buefy=True by default
until we get rid of it altogether
2023-02-02 20:21:19 -06:00
Lance Edgar
9b67010f2c Fix checkbox behavior for Inventory Worksheet 2023-02-02 19:26:47 -06:00
Lance Edgar
f7f8f8dabf Update changelog 2023-02-02 16:51:12 -06:00
Lance Edgar
01182ef752 Add progress bar page for Buefy theme 2023-02-01 23:09:33 -06:00
Lance Edgar
8410419717 Remove support for Buefy 0.8
only Buefy 0.9 and greater are supported now
2023-02-01 18:44:55 -06:00
Lance Edgar
5f7fa33eb2 Update changelog 2023-01-30 21:06:08 -06:00
Lance Edgar
a1d88a5e6b Refactor the Inventory Worksheet generator, per Buefy 2023-01-30 11:56:09 -06:00
Lance Edgar
a3723e4879 Tweak the Ordering Worksheet generator, per Buefy 2023-01-30 11:46:07 -06:00
Lance Edgar
b7f3a67cd0 Add basic API support for printing product labels 2023-01-29 18:46:49 -06:00
Lance Edgar
c880065da8 Tweak customer panel header style for new custorder 2023-01-29 13:02:39 -06:00
Lance Edgar
86af4baef5 Fix icon for multi-file upload widget 2023-01-29 12:45:14 -06:00
Lance Edgar
8cdfe4a22c Update changelog 2023-01-28 16:22:54 -06:00
Lance Edgar
d6f05684be Tweak styles for Quantity panel when viewing Receiving row
when no buttons were visible in panel, right-hand side looked "cut off"
2023-01-28 16:12:03 -06:00
Lance Edgar
17251b2c88 Tweak import handler form, some fields not required
those particular fields are for read-only display, not meant for user
to provide values.  so must provide defaults, else form missing those
will not validate.
2023-01-28 15:54:53 -06:00
Lance Edgar
64acfbcb4e Update changelog 2023-01-26 13:36:14 -06:00
Lance Edgar
55a3f9669b Fix click event for right-aligned buttons on profile view
for some reason when `is-pulled-right` was used, buttons were not
clickable?!  never did figure out precisely why, but this fixes
anyway.  was not an issue w/ buefy 0.8 fwiw, but using 0.9 now
2023-01-26 13:34:13 -06:00
Lance Edgar
884f136e99 Update changelog 2023-01-18 22:04:35 -06:00
Lance Edgar
dc6bd4d4a7 Rename frontend request handler logic to SimpleRequestMixin 2023-01-18 21:56:29 -06:00
Lance Edgar
1e5b7e7ee7 Add a couple more menu items to default set 2023-01-18 21:54:24 -06:00
Lance Edgar
c874d97507 Add default view config for Trainwreck 2023-01-18 20:11:46 -06:00
Lance Edgar
3f61c9ee18 Add some more menu items to default set 2023-01-18 19:21:34 -06:00
Lance Edgar
eece358e20 Update changelog 2023-01-18 18:58:32 -06:00
Lance Edgar
2b1fd9e986 Add way to override particular 'essential' views 2023-01-18 18:41:23 -06:00
Lance Edgar
79e4e596e8 Include permission views by default 2023-01-18 17:58:04 -06:00
Lance Edgar
23dea7bced Add more views, menus to default set 2023-01-18 16:55:30 -06:00
Lance Edgar
e4c2336659 Add specific data type options for new table entry form
including basic FK / relationship support
2023-01-17 00:00:06 -06:00
Lance Edgar
98fa6eea05 Misc. tweaks for App Details / Configure Menus 2023-01-16 21:55:52 -06:00
Lance Edgar
9b21d52206 Update changelog 2023-01-16 18:44:54 -06:00
Lance Edgar
00548a259b Add basic "new model view" wizard 2023-01-16 13:50:27 -06:00
Lance Edgar
f4bc280da7 Wrap up steps for new table wizard
it actually works.. :)  needs more polish, but will let usage drive that
2023-01-15 22:52:01 -06:00
Lance Edgar
68ed5942e6 Add basic "Review Model" step for new table wizard 2023-01-14 23:23:21 -06:00
Lance Edgar
9d2bcff96b Add full set of default menus
plus dynamic set of integration menus, from providers
2023-01-14 18:48:56 -06:00
Lance Edgar
39d53617bd Add new handlers, TailboneHandler and MenuHandler 2023-01-14 16:01:26 -06:00
Lance Edgar
cfdaa1e927 Add default logic to get merge data for object 2023-01-14 12:17:05 -06:00
Lance Edgar
aef679c030 Fix bug when adding new profile via datasync configure 2023-01-14 11:51:22 -06:00
Lance Edgar
dec0ebba30 Let the API "rawbytes" response be just that, w/ no file 2023-01-14 10:31:31 -06:00
Lance Edgar
e82e27acd7 Update changelog 2023-01-14 08:40:08 -06:00
Lance Edgar
23358d9c5d Tweak how backfill task is launched
per upstream changes
2023-01-14 02:20:21 -06:00
Lance Edgar
80989cc84f Update changelog 2023-01-13 20:53:26 -06:00
Lance Edgar
d8bd4bd847 Prevent listing for top-level Messages view
user must access inbox, archive etc. directly instead
2023-01-13 20:28:00 -06:00
Lance Edgar
f18f24962e Refactor tempmon dashboard view, for buefy themes 2023-01-13 20:18:42 -06:00
Lance Edgar
0753e956f9 Revert logic that assumes all themes use buefy
that just isn't a safe assumption yet..alas
2023-01-13 18:10:28 -06:00
Lance Edgar
83f9a3faa7 Fix "toggle batch complete" for Chrome browser 2023-01-13 16:49:16 -06:00
Lance Edgar
cac005f993 Semi-finish logic for writing new table model class to file
definitely needs more polish and features, but the gist..
2023-01-13 03:51:12 -06:00
Lance Edgar
fb7368993c Show basic column info as row grid when viewing Table 2023-01-12 22:56:12 -06:00
Lance Edgar
38f88407ff Update changelog 2023-01-12 15:33:56 -06:00
Lance Edgar
d842a3d8e0 Add new views for App Info, and Configure App
and a way to specify version/url overrides for buefy, vue etc.

also, begin logic for "standard" admin menu
2023-01-12 15:30:10 -06:00
Lance Edgar
2163522e7c Update changelog 2023-01-11 23:31:09 -06:00
Lance Edgar
225e13f43b Allow all external dependency URLs to be set in config
so can host all files locally if needed.

we also now assume all themes support buefy unless otherwise
configured
2023-01-11 23:29:28 -06:00
Lance Edgar
fa1cf353b8 Update changelog 2023-01-11 19:55:52 -06:00
Lance Edgar
4746b6fae9 Refactor inventory batch "add row" page, per new theme 2023-01-11 19:31:14 -06:00
Lance Edgar
2c7f2c0fcd Fix panel header icon behavior for new custorder
had to work around a buefy bug..?
2023-01-11 16:41:11 -06:00
Lance Edgar
b8389c72bb Add support for per-item default discount, for new custorder 2023-01-11 16:41:07 -06:00
Lance Edgar
dfa4178204 Add basic support for receiving from multiple invoice files 2023-01-10 16:46:21 -06:00
Lance Edgar
2b7ebedb22 Update changelog 2023-01-08 11:36:42 -06:00
Lance Edgar
33ffd7e855 Improve "download rows as XLSX" for importer batch
still could be better, but at least this avoids error
2023-01-07 22:46:35 -06:00
Lance Edgar
b11f9f62b7 Update changelog 2023-01-07 11:53:10 -06:00
Lance Edgar
c6765fd9a9 Expose, start to honor "units only" setting for products 2023-01-07 11:52:37 -06:00
Lance Edgar
8c201dced7 Update changelog 2023-01-05 13:43:38 -06:00
Lance Edgar
71851e1a05 Use product handler to normalize data for products API
at least, as much as possible
2023-01-04 21:23:57 -06:00
Lance Edgar
db62bd20b3 Show help link when generating or viewing report, if applicable 2023-01-04 16:39:37 -06:00
Lance Edgar
31b213610f Fix template bug for generating report 2023-01-04 15:31:51 -06:00
Lance Edgar
d0881cbd09 Keep aspect ratio for product images in new custorder 2023-01-04 12:38:04 -06:00
Lance Edgar
7e4bd851f1 Update changelog 2023-01-04 10:57:14 -06:00
Lance Edgar
ab80aedb63 Allow xref buttons to have "internal" links
still assume external (open in new tab) by default
2023-01-04 00:09:35 -06:00
Lance Edgar
c7537e7994 Update changelog 2023-01-02 16:55:39 -06:00
Lance Edgar
9f763b46eb Expose some settings for Trainwreck DB rotation 2023-01-02 13:12:01 -06:00
Lance Edgar
d21826c70d Make invalid email more obvious, in profile view 2023-01-02 11:11:01 -06:00
Lance Edgar
a061e362c3 Add beginnings of "New Table" feature
nowhere near complete yet, but skeleton is more or less in place
2023-01-02 09:44:05 -06:00
Lance Edgar
7e852c1836 Allow buefy version to be 'latest' 2023-01-01 13:17:55 -06:00
Lance Edgar
a01982ae55 Show only "core" app settings by default 2022-12-31 17:57:22 -06:00
Lance Edgar
884f960d3b Update changelog 2022-12-28 16:12:33 -06:00
Lance Edgar
0c6bfcbee6 Use minified version of vue.js by default, in falafel theme 2022-12-28 14:40:50 -06:00
Lance Edgar
03639d73fa Show global search as button instead of link 2022-12-27 22:51:42 -06:00
Lance Edgar
cfc92ac9e7 Hide the "configure field help" icons until user requests access
user can technically "request access" on "any page" and not just those
with configurable fields..but who cares for now i think..
2022-12-27 22:30:25 -06:00
Lance Edgar
dc90abcf09 Add "global searchbox" for quicker access to main views 2022-12-26 17:31:37 -06:00
Lance Edgar
b985124bef Fix product image view for python3 2022-12-26 10:33:12 -06:00
Lance Edgar
b653351f71 Avoid error when no form present 2022-12-25 23:05:53 -06:00
Lance Edgar
0a0b471a03 Add support for websockets over HTTP
in addition to HTTPS
2022-12-25 15:37:54 -06:00
Lance Edgar
c389ebabd0 Show *correct* system title when upgrading
may not be the same as primary app title
2022-12-25 15:13:59 -06:00
Lance Edgar
8264a69cec Add "direct link" support for master grids 2022-12-25 14:42:29 -06:00
Lance Edgar
cd466a64e5 Filter by person instead of user, for Generated Reports "Created by" 2022-12-25 12:45:23 -06:00
Lance Edgar
b04c1054fc Override document title when upgrading
when using websockets, to mimic old behavior without them
2022-12-25 12:25:55 -06:00
Lance Edgar
3befdc09e3 Add basic support for editing field help info 2022-12-24 21:46:02 -06:00
Lance Edgar
9fe9983bf9 Add basic support for editing page help info
site admin should be able to point help wherever they want
2022-12-24 16:08:09 -06:00
Lance Edgar
ed54092268 Add cleanup logic for old Beaker session data
pretty basic, but good enough for now
2022-12-23 23:30:45 -06:00
Lance Edgar
50dafc91d4 Preserve current tab for page reload in profile view
also makes sharing links better etc.
2022-12-23 20:58:27 -06:00
Lance Edgar
d409e1d088 Make static files optional, for new tailbone-integration project 2022-12-23 20:18:49 -06:00
Lance Edgar
64c8768314 Fix checkbox values when re-running a report 2022-12-23 19:43:31 -06:00
Lance Edgar
c5bd40793b Fix HUD display when toggling employee status in profile view 2022-12-23 19:06:05 -06:00
Lance Edgar
8a6fdb5ea5 Warn user when luigi is not installed, for relevant view
better than getting a server error
2022-12-23 18:55:53 -06:00
Lance Edgar
6fbc79fe5e Add support for Buefy 0.9.x
or: add hacks to continue supporting Buefy 0.8.x

..depending on your perspective
2022-12-22 20:49:20 -06:00
Lance Edgar
7ccd9ad896 Update changelog 2022-12-21 20:01:31 -06:00
Lance Edgar
ef9dc9ff6d Expose the terms field for Vendor CRUD 2022-12-21 18:05:38 -06:00
Lance Edgar
ed0a1f2740 Add make_status_renderer() to MasterView
batches aren't the only table/view where a status code/text combo may
be in use
2022-12-20 19:15:31 -06:00
Lance Edgar
871ea84f96 Add support for "is row checkable" in grids
i.e. when grid has checkboxes, some rows maybe shouldn't get one
2022-12-20 19:14:54 -06:00
Lance Edgar
e427e50d67 Update changelog 2022-12-15 13:32:27 -06:00
Lance Edgar
99a5615e91 Add configure_execute_form() hook for batch views
also enable bulk-delete of row results by default for batch views
2022-12-15 09:12:26 -06:00
Lance Edgar
c8201de2ff Update changelog 2022-12-10 12:41:41 -06:00
Lance Edgar
3c54960612 Fix error if no view supplements defined 2022-12-10 12:41:10 -06:00
Lance Edgar
5045df0b57 Update changelog 2022-12-10 11:35:02 -06:00
Lance Edgar
f388f84b07 Suppress error if menu entry has bad route name 2022-12-10 10:09:39 -06:00
Lance Edgar
f8f6b76657 Add xref buttons for Customer, Member tabs in profile view 2022-12-10 09:48:22 -06:00
Lance Edgar
cb6c25f829 Let view supps give data instead of actual xref button 2022-12-10 09:48:22 -06:00
Lance Edgar
05a3e3f805 Add common logic to determine panel fields for product view
so we don't have to override templates, but just the view logic

more needed, but this proves the concept
2022-12-10 09:48:22 -06:00
Lance Edgar
273fa7eb55 Add common logic for xref buttons, links when viewing object
about dang time for this..probaby needs improvement but a good start
2022-12-10 09:48:22 -06:00
Lance Edgar
2278082a4d Cleanup employees view per new supplements
also add permission for "view employee secrets" (where applicable)
2022-12-10 09:48:22 -06:00
Lance Edgar
d5d9c644a2 Add the ViewSupplement concept
also fix cell-class for grid columns.  cannot use "raw" fieldname
because in some cases (e.g. 'number', 'rate') Bulma may interpret that
as actually meaning something, and affect the display
2022-12-10 09:46:54 -06:00
Lance Edgar
1a51f3d854 Fix ordering worksheet API for date objects 2022-12-08 14:54:36 -06:00
Lance Edgar
f80d3cd530 Show simple error string, when subprocess batch actions fail
logs still have more info, can't show user the whole traceback..but
this is better than we had before..
2022-12-08 14:15:38 -06:00
397 changed files with 35088 additions and 24532 deletions

3
.gitignore vendored
View file

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

683
CHANGELOG.md Normal file
View file

@ -0,0 +1,683 @@
# Changelog
All notable changes to Tailbone will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.22.7 (2025-02-19)
### Fix
- stop using old config for logo image url on login page
- fix warning msg for deprecated Grid param
## v0.22.6 (2025-02-01)
### Fix
- register vue3 form component for products -> make batch
## v0.22.5 (2024-12-16)
### Fix
- whoops this is latest rattail
- require newer rattail lib
- require newer wuttaweb
- let caller request safe HTML literal for rendered grid table
## v0.22.4 (2024-11-22)
### Fix
- avoid error in product search for duplicated key
- use vmodel for confirm password widget input
## v0.22.3 (2024-11-19)
### Fix
- avoid error for trainwreck query when not a customer
## v0.22.2 (2024-11-18)
### Fix
- use local/custom enum for continuum operations
- add basic master view for Product Costs
- show continuum operation type when viewing version history
- always define `app` attr for ViewSupplement
- avoid deprecated import
## v0.22.1 (2024-11-02)
### Fix
- fix submit button for running problem report
- avoid deprecated grid method
## v0.22.0 (2024-10-22)
### Feat
- add support for new ordering batch from parsed file
### Fix
- avoid deprecated method to suggest username
## v0.21.11 (2024-10-03)
### Fix
- custom method for adding grid action
- become/stop root should redirect to previous url
## v0.21.10 (2024-09-15)
### Fix
- update project repo links, kallithea -> forgejo
- use better icon for submit button on login page
- wrap notes text for batch view
- expose datasync consumer batch size via configure page
## v0.21.9 (2024-08-28)
### Fix
- render custom attrs in form component tag
## v0.21.8 (2024-08-28)
### Fix
- ignore session kwarg for `MasterView.make_row_grid()`
## v0.21.7 (2024-08-28)
### Fix
- avoid error when form value cannot be obtained
## v0.21.6 (2024-08-28)
### Fix
- avoid error when grid value cannot be obtained
## v0.21.5 (2024-08-28)
### Fix
- set empty string for "-new-" file configure option
## v0.21.4 (2024-08-26)
### Fix
- handle differing email profile keys for appinfo/configure
## v0.21.3 (2024-08-26)
### Fix
- show non-standard config values for app info configure email
## v0.21.2 (2024-08-26)
### Fix
- refactor waterpark base template to use wutta feedback component
- fix input/output file upload feature for configure pages, per oruga
- tweak how grid data translates to Vue template context
- merge filters into main grid template
- add basic wutta view for users
- some fixes for wutta people view
- various fixes for waterpark theme
- avoid deprecated `component` form kwarg
## v0.21.1 (2024-08-22)
### Fix
- misc. bugfixes per recent changes
## v0.21.0 (2024-08-22)
### Feat
- move "most" filtering logic for grid class to wuttaweb
- inherit from wuttaweb templates for home, login pages
- inherit from wuttaweb for AppInfoView, appinfo/configure template
- add "has output file templates" config option for master view
### Fix
- change grid reset-view param name to match wuttaweb
- move "searchable columns" grid feature to wuttaweb
- use wuttaweb to get/render csrf token
- inherit from wuttaweb for appinfo/index template
- prefer wuttaweb config for "home redirect to login" feature
- fix master/index template rendering for waterpark theme
- fix spacing for navbar logo/title in waterpark theme
## v0.20.1 (2024-08-20)
### Fix
- fix default filter verbs logic for workorder status
## v0.20.0 (2024-08-20)
### Feat
- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy
- refactor templates to simplify base/page/form structure
### Fix
- avoid deprecated reference to app db engine
## v0.19.3 (2024-08-19)
### Fix
- add pager stats to all grid vue data (fixes view history)
## v0.19.2 (2024-08-19)
### Fix
- sort on frontend for appinfo package listing grid
- prefer attr over key lookup when getting model values
- replace all occurrences of `component_studly` => `vue_component`
## v0.19.1 (2024-08-19)
### Fix
- fix broken user auth for web API app
## v0.19.0 (2024-08-18)
### Feat
- move multi-column grid sorting logic to wuttaweb
- move single-column grid sorting logic to wuttaweb
### Fix
- fix misc. errors in grid template per wuttaweb
- fix broken permission directives in web api startup
## v0.18.0 (2024-08-16)
### Feat
- move "basic" grid pagination logic to wuttaweb
- inherit from wutta base class for Grid
- inherit most logic from wuttaweb, for GridAction
### Fix
- avoid route error in user view, when using wutta people view
- fix some more wutta compat for base template
## v0.17.0 (2024-08-15)
### Feat
- use wuttaweb for `get_liburl()` logic
## v0.16.1 (2024-08-15)
### Fix
- improve wutta People view a bit
- update references to `get_class_hierarchy()`
- tweak template for `people/view_profile` per wutta compat
## v0.16.0 (2024-08-15)
### Feat
- add first wutta-based master, for PersonView
- refactor forms/grids/views/templates per wuttaweb compat
## v0.15.6 (2024-08-13)
### Fix
- avoid `before_render` subscriber hook for web API
- simplify verbiage for batch execution panel
## v0.15.5 (2024-08-09)
### Fix
- assign convenience attrs for all views (config, app, enum, model)
## v0.15.4 (2024-08-09)
### Fix
- avoid bug when checking current theme
## v0.15.3 (2024-08-08)
### Fix
- fix timepicker `parseTime()` when value is null
## v0.15.2 (2024-08-06)
### Fix
- use auth handler, avoid legacy calls for role/perm checks
## v0.15.1 (2024-08-05)
### Fix
- move magic `b` template context var to wuttaweb
## v0.15.0 (2024-08-05)
### Feat
- move more subscriber logic to wuttaweb
### Fix
- use wuttaweb logic for `util.get_form_data()`
## v0.14.5 (2024-08-03)
### Fix
- use auth handler instead of deprecated auth functions
- avoid duplicate `partial` param when grid reloads data
## v0.14.4 (2024-07-18)
### Fix
- fix more settings persistence bug(s) for datasync/configure
- fix modals for luigi tasks page, per oruga
## v0.14.3 (2024-07-17)
### Fix
- fix auto-collapse title for viewing trainwreck txn
- allow auto-collapse of header when viewing trainwreck txn
## v0.14.2 (2024-07-15)
### Fix
- add null menu handler, for use with API apps
## v0.14.1 (2024-07-14)
### Fix
- update usage of auth handler, per rattail changes
- fix model reference in menu handler
- fix bug when making "integration" menus
## v0.14.0 (2024-07-14)
### Feat
- move core menu logic to wuttaweb
## v0.13.2 (2024-07-13)
### Fix
- fix logic bug for datasync/config settings save
## v0.13.1 (2024-07-13)
### Fix
- fix settings persistence bug(s) for datasync/configure page
## v0.13.0 (2024-07-12)
### Feat
- begin integrating WuttaWeb as upstream dependency
### Fix
- cast enum as list to satisfy deform widget
## v0.12.1 (2024-07-11)
### Fix
- refactor `config.get_model()` => `app.model`
## v0.12.0 (2024-07-09)
### Feat
- drop python 3.6 support, use pyproject.toml (again)
## v0.11.10 (2024-07-05)
### Fix
- make the Members tab optional, for profile view
## v0.11.9 (2024-07-05)
### Fix
- do not show flash message when changing app theme
- improve collapse panels for butterball theme
- expand input for butterball theme
- add xref button to customer profile, for trainwreck txn view
- add optional Transactions tab for profile view
## v0.11.8 (2024-07-04)
### Fix
- fix grid action icons for datasync/configure, per oruga
- allow view supplements to add extra links for profile employee tab
- leverage import handler method to determine command/subcommand
- add tool to make user account from profile view
## v0.11.7 (2024-07-04)
### Fix
- add stacklevel to deprecation warnings
- require zope.sqlalchemy >= 1.5
- include edit profile email/phone dialogs only if user has perms
- allow view supplements to add to profile member context
- cast enum as list to satisfy deform widget
- expand POD image URL setting input
## v0.11.6 (2024-07-01)
### Fix
- set explicit referrer when changing dbkey
- remove references, dependency for `six` package
## v0.11.5 (2024-06-30)
### Fix
- allow comma in numeric filter input
- add custom url prefix if needed, for fanstatic
- use vue 3.4.31 and oruga 0.8.12 by default
## v0.11.4 (2024-06-30)
### Fix
- start/stop being root should submit POST instead of GET
- require vendor when making new ordering batch via api
- don't escape each address for email attempts grid
## v0.11.3 (2024-06-28)
### Fix
- add link to "resolved by" user for pending products
- handle error when merging 2 records fails
## v0.11.2 (2024-06-18)
### Fix
- hide certain custorder settings if not applicable
- use different logic for buefy/oruga for product lookup keydown
- product records should be touchable
- show flash error message if resolve pending product fails
## v0.11.1 (2024-06-14)
### Fix
- revert back to setup.py + setup.cfg
## v0.11.0 (2024-06-10)
### Feat
- switch from setup.cfg to pyproject.toml + hatchling
## v0.10.16 (2024-06-10)
### Feat
- standardize how app, package versions are determined
### Fix
- avoid deprecated config methods for app/node title
## v0.10.15 (2024-06-07)
### Fix
- do *not* Use `pkg_resources` to determine package versions
## v0.10.14 (2024-06-06)
### Fix
- use `pkg_resources` to determine package versions
## v0.10.13 (2024-06-06)
### Feat
- remove old/unused scaffold for use with `pcreate`
- add 'fanstatic' support for sake of libcache assets
## v0.10.12 (2024-06-04)
### Feat
- require pyramid 2.x; remove 1.x-style auth policies
- remove version cap for deform
- set explicit referrer when changing app theme
- add `<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.

View file

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

File diff suppressed because it is too large Load diff

6
docs/api/db.rst Normal file
View file

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

6
docs/api/diffs.rst Normal file
View file

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

View file

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

View file

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

6
docs/api/util.rst Normal file
View file

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

View file

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

View file

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

8
docs/changelog.rst Normal file
View file

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

View file

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

View file

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

103
pyproject.toml Normal file
View file

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

View file

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

190
setup.py
View file

@ -1,190 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <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,3 +1,9 @@
# -*- coding: utf-8; -*-
__version__ = '0.8.268'
try:
from importlib.metadata import version
except ImportError:
from importlib_metadata import version
__version__ = version('Tailbone')

View file

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

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,15 +24,12 @@
Tailbone Web API - Inventory Batches
"""
from __future__ import unicode_literals, absolute_import
import decimal
import six
import sqlalchemy as sa
from rattail import pod
from rattail.db import model
from rattail.util import pretty_quantity
from rattail.db.model import InventoryBatch, InventoryBatchRow
from cornice import Service
@ -41,7 +38,7 @@ from tailbone.api.batch import APIBatchView, APIBatchRowView
class InventoryBatchViews(APIBatchView):
model_class = model.InventoryBatch
model_class = InventoryBatch
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
route_prefix = 'inventory'
permission_prefix = 'batch.inventory'
@ -50,12 +47,12 @@ class InventoryBatchViews(APIBatchView):
supports_toggle_complete = True
def normalize(self, batch):
data = super(InventoryBatchViews, self).normalize(batch)
data = super().normalize(batch)
data['mode'] = batch.mode
data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode)
if data['mode_display'] is None and batch.mode is not None:
data['mode_display'] = six.text_type(batch.mode)
data['mode_display'] = str(batch.mode)
data['reason_code'] = batch.reason_code
@ -119,7 +116,7 @@ class InventoryBatchViews(APIBatchView):
class InventoryBatchRowViews(APIBatchRowView):
model_class = model.InventoryBatchRow
model_class = InventoryBatchRow
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
route_prefix = 'inventory.rows'
permission_prefix = 'batch.inventory'
@ -130,23 +127,24 @@ class InventoryBatchRowViews(APIBatchRowView):
def normalize(self, row):
batch = row.batch
data = super(InventoryBatchRowViews, self).normalize(row)
data = super().normalize(row)
app = self.get_rattail_app()
data['item_id'] = row.item_id
data['upc'] = six.text_type(row.upc)
data['upc'] = str(row.upc)
data['upc_pretty'] = row.upc.pretty() if row.upc else None
data['brand_name'] = row.brand_name
data['description'] = row.description
data['size'] = row.size
data['full_description'] = row.product.full_description if row.product else row.description
data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
data['case_quantity'] = pretty_quantity(row.case_quantity or 1)
data['case_quantity'] = app.render_quantity(row.case_quantity or 1)
data['cases'] = row.cases
data['units'] = row.units
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
data['quantity_display'] = "{} {}".format(
pretty_quantity(row.cases or row.units),
app.render_quantity(row.cases or row.units),
'CS' if row.cases else data['unit_uom'])
data['allow_cases'] = self.batch_handler.allow_cases(batch)
@ -174,7 +172,17 @@ class InventoryBatchRowViews(APIBatchRowView):
data['units'] = decimal.Decimal(data['units'])
# update row per usual
row = super(InventoryBatchRowViews, self).update_object(row, data)
try:
row = super().update_object(row, data)
except sa.exc.DataError as error:
# detect when user scans barcode for cases/units field
if hasattr(error, 'orig'):
orig = type(error.orig)
if hasattr(orig, '__name__'):
# nb. this particular error is from psycopg2
if orig.__name__ == 'NumericValueOutOfRange':
return {'error': "Numeric value out of range"}
raise
return row

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

80
tailbone/cleanup.py Normal file
View file

@ -0,0 +1,80 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <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-2022 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,15 +24,16 @@
Rattail config extension for Tailbone
"""
from __future__ import unicode_literals, absolute_import
import warnings
from wuttjamaican.conf import WuttaConfigExtension
from rattail.config import ConfigExtension as BaseExtension
from rattail.db.config import configure_session
from tailbone.db import Session
class ConfigExtension(BaseExtension):
class ConfigExtension(WuttaConfigExtension):
"""
Rattail config extension for Tailbone. Does the following:
@ -49,9 +50,12 @@ class ConfigExtension(BaseExtension):
configure_session(config, Session)
# provide default theme selection
config.setdefault('tailbone', 'themes', 'default, falafel')
config.setdefault('tailbone', 'themes.keys', 'default, butterball')
config.setdefault('tailbone', 'themes.expose_picker', 'true')
# override oruga detection
config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga')
def csrf_token_name(config):
return config.get('tailbone', 'csrf_token_name', default='_csrf')

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2019 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,12 +24,9 @@
Form Schema Types
"""
from __future__ import unicode_literals, absolute_import
import re
import datetime
import six
import json
from rattail.db import model
from rattail.gpc import GPC
@ -37,6 +34,7 @@ from rattail.gpc import GPC
import colander
from tailbone.db import Session
from tailbone.forms import widgets
class JQueryTime(colander.Time):
@ -76,6 +74,76 @@ 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.
@ -84,7 +152,7 @@ class GPCType(colander.SchemaType):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return six.text_type(appstruct)
return str(appstruct)
def deserialize(self, node, cstruct):
if not cstruct:
@ -95,7 +163,7 @@ class GPCType(colander.SchemaType):
try:
return GPC(digits)
except Exception as err:
raise colander.Invalid(node, six.text_type(err))
raise colander.Invalid(node, str(err))
class ProductQuantity(colander.MappingSchema):
@ -133,12 +201,12 @@ class ModelType(colander.SchemaType):
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return six.text_type(appstruct)
return str(appstruct)
def deserialize(self, node, cstruct):
if not cstruct:
return None
obj = self.session.query(self.model_class).get(cstruct)
obj = self.session.get(self.model_class, 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-2022 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,20 +24,16 @@
Form Widgets
"""
from __future__ import unicode_literals, absolute_import, division
import json
import datetime
import decimal
import six
import re
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):
@ -45,6 +41,7 @@ class ReadonlyWidget(dfwidget.HiddenWidget):
readonly = True
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
# TODO: is this hacky?
@ -61,11 +58,11 @@ class NumberInputWidget(dfwidget.TextInputWidget):
class NumericInputWidget(NumberInputWidget):
"""
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.
This widget uses a ``<numeric-input>`` component, which will
leverage the ``numeric.js`` functions to ensure user doesn't enter
any non-numeric values. Note that this still uses a normal "text"
input on the HTML side, as opposed to a "number" input, since the
latter is a bit ugly IMHO.
"""
template = 'numericinput'
allow_enter = True
@ -82,15 +79,17 @@ 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 = six.text_type(value)
return super(PercentInputWidget, self).serialize(field, cstruct, **kw)
cstruct = str(value)
return super().serialize(field, cstruct, **kw)
def deserialize(self, field, pstruct):
pstruct = super(PercentInputWidget, self).deserialize(field, pstruct)
""" """
pstruct = super().deserialize(field, pstruct)
if pstruct is colander.null:
return colander.null
# convert "human-friendly" value to "traditional"
@ -100,7 +99,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 six.text_type(value)
return str(value)
class CasesUnitsWidget(dfwidget.Widget):
@ -113,6 +112,7 @@ 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,6 +123,9 @@ 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
@ -151,6 +154,7 @@ class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
template = 'checkbox_dynamic'
# TODO: deprecate / remove this
class PlainSelectWidget(dfwidget.SelectWidget):
template = 'select_plain'
@ -169,7 +173,7 @@ class CustomSelectWidget(dfwidget.SelectWidget):
self.extra_template_values.update(kw)
def get_template_values(self, field, cstruct, kw):
values = super(CustomSelectWidget, self).get_template_values(field, cstruct, kw)
values = super().get_template_values(field, cstruct, kw)
if hasattr(self, 'extra_template_values'):
values.update(self.extra_template_values)
return values
@ -212,6 +216,7 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
)
def serialize(self, field, cstruct, **kw):
""" """
if cstruct in (colander.null, None):
cstruct = ''
readonly = kw.get('readonly', self.readonly)
@ -239,6 +244,48 @@ 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
@ -261,6 +308,7 @@ 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 '
@ -289,6 +337,114 @@ 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
@ -313,13 +469,16 @@ def make_customer_widget(request, **kwargs):
class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
"""
Autocomplete widget for a Customer reference field.
Autocomplete widget for a
:class:`~rattail:rattail.db.model.customers.Customer` reference
field.
"""
def __init__(self, request, *args, **kwargs):
super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.request = request
model = self.request.rattail_config.get_model()
app = self.request.rattail_config.get_app()
model = app.model
# must figure out URL providing autocomplete service
if 'service_url' not in kwargs:
@ -337,26 +496,30 @@ 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:
model = self.request.rattail_config.get_model()
customer = Session.query(model.Customer).get(cstruct)
app = self.request.rattail_config.get_app()
model = app.model
customer = Session.get(model.Customer, cstruct)
if customer:
self.field_display = six.text_type(customer)
self.field_display = str(customer)
return super(CustomerAutocompleteWidget, self).serialize(
return super().serialize(
field, cstruct, **kw)
class CustomerDropdownWidget(dfwidget.SelectWidget):
"""
Dropdown widget for a Customer reference field.
Dropdown widget for a
:class:`~rattail:rattail.db.model.customers.Customer` reference
field.
"""
def __init__(self, request, *args, **kwargs):
super(CustomerDropdownWidget, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.request = request
app = self.request.rattail_config.get_app()
# must figure out dropdown values, if they weren't given
if 'values' not in kwargs:
@ -368,10 +531,8 @@ class CustomerDropdownWidget(dfwidget.SelectWidget):
customers = customers()
else: # default customer list
model = self.request.rattail_config.get_model()
customers = Session.query(model.Customer)\
.order_by(model.Customer.name)\
.all()
customers = app.get_clientele_handler()\
.get_all_customers(Session())
# convert customer list to option values
self.values = [(c.uuid, c.name)
@ -393,13 +554,106 @@ class DepartmentWidget(dfwidget.SelectWidget):
def __init__(self, request, **kwargs):
if 'values' not in kwargs:
model = request.rattail_config.get_model()
app = request.rattail_config.get_app()
model = app.model
departments = Session.query(model.Department)\
.order_by(model.Department.number)
values = [(dept.uuid, six.text_type(dept))
values = [(dept.uuid, str(dept))
for dept in departments]
if not kwargs.pop('required', True):
values.insert(0, ('', "(none)"))
kwargs['values'] = values
super(DepartmentWidget, self).__init__(**kwargs)
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]

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,17 +24,15 @@
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
@ -117,7 +115,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], six.text_type(k)) for k in sorted_keys]
self.options = [tags.Option(enum[k], str(k)) for k in sorted_keys]
class GridFilter(object):
@ -173,18 +171,25 @@ class GridFilter(object):
data_type = 'string' # default, but will be set from value renderer
choices = {}
def __init__(self, key, label=None, verbs=None, value_enum=None, value_renderer=None,
def __init__(self, key, config=None, 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
@ -272,14 +277,15 @@ class GridFilter(object):
value = self.get_value(value)
filtr = getattr(self, 'filter_{0}'.format(verb), None)
if not filtr:
raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
log.warning("unknown filter verb: %s", verb)
return data
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, six.string_types):
if self.encode_values and isinstance(value, str):
return value.encode('utf-8')
return value
@ -308,7 +314,7 @@ class AlchemyGridFilter(GridFilter):
def __init__(self, *args, **kwargs):
self.column = kwargs.pop('column')
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
def filter_equal(self, query, value):
"""
@ -332,6 +338,38 @@ 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
@ -410,12 +448,12 @@ class AlchemyGridFilter(GridFilter):
if start_value:
if self.value_invalid(start_value):
return query
query = query.filter(self.column >= start_value)
query = query.filter(self.column >= self.encode_value(start_value))
if end_value:
if self.value_invalid(end_value):
return query
query = query.filter(self.column <= end_value)
query = query.filter(self.column <= self.encode_value(end_value))
return query
@ -429,9 +467,13 @@ 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', 'not_equal', 'equal_any_of',
'is_empty', 'is_not_empty',
'is_null', 'is_not_null',
'is_empty_or_null',
@ -443,9 +485,13 @@ class AlchemyStringFilter(AlchemyGridFilter):
"""
if value is None or value == '':
return query
return query.filter(sa.and_(
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]))
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))
def filter_does_not_contain(self, query, value):
"""
@ -454,14 +500,17 @@ 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_(
*[~self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]),
))
sa.and_(*criteria)))
def filter_contains_any_of(self, query, value):
"""
@ -490,24 +539,28 @@ class AlchemyStringFilter(AlchemyGridFilter):
conditions = []
for value in values:
conditions.append(sa.and_(
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
for v in value.split()]))
criteria = []
for val in value.split():
val = val.replace('_', r'\_')
val = self.encode_value(f'%{val}%')
criteria.append(self.column.ilike(val))
conditions.append(sa.and_(*criteria))
return query.filter(sa.or_(*conditions))
def filter_is_empty(self, query, value):
return query.filter(sa.func.trim(self.column) == self.encode_value(''))
return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))
def filter_is_not_empty(self, query, value):
return query.filter(sa.func.trim(self.column) != self.encode_value(''))
return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))
def filter_is_empty_or_null(self, query, value):
return query.filter(
sa.or_(
sa.func.trim(self.column) == self.encode_value(''),
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''),
self.column == None))
class AlchemyEmptyStringFilter(AlchemyStringFilter):
"""
String filter with special logic to treat empty string values as NULL
@ -517,13 +570,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter):
return query.filter(
sa.or_(
self.column == None,
sa.func.trim(self.column) == self.encode_value('')))
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')))
def filter_is_not_null(self, query, value):
return query.filter(
sa.and_(
self.column != None,
sa.func.trim(self.column) != self.encode_value('')))
sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')))
class AlchemyByteStringFilter(AlchemyStringFilter):
@ -535,8 +588,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
value_encoding = 'utf-8'
def get_value(self, value=UNSPECIFIED):
value = super(AlchemyByteStringFilter, self).get_value(value)
if isinstance(value, six.text_type):
value = super().get_value(value)
if isinstance(value, str):
value = value.encode(self.value_encoding)
return value
@ -546,8 +599,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
"""
if value is None or value == '':
return query
return query.filter(sa.and_(
*[self.column.ilike(b'%{}%'.format(v)) for v in value.split()]))
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))
def filter_does_not_contain(self, query, value):
"""
@ -556,13 +614,16 @@ 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_(
*[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]),
))
sa.and_(*criteria)))
class AlchemyNumericFilter(AlchemyGridFilter):
@ -571,10 +632,11 @@ class AlchemyNumericFilter(AlchemyGridFilter):
"""
value_renderer_factory = NumericValueRenderer
# 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']
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']
# 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
@ -586,47 +648,66 @@ class AlchemyNumericFilter(AlchemyGridFilter):
# first just make sure it's somewhat numeric
try:
float(value)
except ValueError:
self.parse_decimal(value)
except decimal.InvalidOperation:
return True
return bool(value and len(six.text_type(value)) > 8)
return bool(value and len(str(value)) > 8)
def parse_decimal(self, value):
if value:
value = value.replace(',', '')
return decimal.Decimal(value)
def encode_value(self, value):
if value:
value = str(self.parse_decimal(value))
return super().encode_value(value)
def filter_equal(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_equal(query, value)
return super().filter_equal(query, value)
def filter_not_equal(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_not_equal(query, value)
return super().filter_not_equal(query, value)
def filter_greater_than(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_greater_than(query, value)
return super().filter_greater_than(query, value)
def filter_greater_equal(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_greater_equal(query, value)
return super().filter_greater_equal(query, value)
def filter_less_than(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_less_than(query, value)
return super().filter_less_than(query, value)
def filter_less_equal(self, query, value):
if self.value_invalid(value):
return query
return super(AlchemyNumericFilter, self).filter_less_equal(query, value)
return super().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:
@ -634,9 +715,10 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
return True
if not value.isdigit():
return True
# 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:
# normal Integer columns have a max value, beyond which PG
# will throw an error if we try to query for larger values
# TODO: this seems hacky, how to better handle it?
if not self.bigint and int(value) > 2147483647:
return True
return False
@ -646,6 +728,13 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
return int(value)
class AlchemyBigIntegerFilter(AlchemyIntegerFilter):
"""
BigInteger filter for SQLAlchemy.
"""
bigint = True
class AlchemyBooleanFilter(AlchemyGridFilter):
"""
Boolean filter for SQLAlchemy.
@ -1134,7 +1223,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
'ILIKE' query with those parts.
"""
value = self.parse_value(value)
return super(AlchemyPhoneNumberFilter, self).filter_contains(query, value)
return super().filter_contains(query, value)
def filter_does_not_contain(self, query, value):
"""
@ -1142,7 +1231,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter):
'NOT ILIKE' query with those parts.
"""
value = self.parse_value(value)
return super(AlchemyPhoneNumberFilter, self).filter_does_not_contain(query, value)
return super().filter_does_not_contain(query, value)
class GridFilterSet(OrderedDict):
@ -1186,7 +1275,7 @@ class GridFiltersForm(forms.Form):
node = colander.SchemaNode(colander.String(), name=key)
schema.add(node)
kwargs['schema'] = schema
super(GridFiltersForm, self).__init__(**kwargs)
super().__init__(**kwargs)
def iter_filters(self):
return self.filters.values()

82
tailbone/handler.py Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
@ -48,6 +48,9 @@ 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">${cost.case_size} ${"LB" if cost.product.weighed else "EA"}</td>
<td class="case-qty">${app.render_quantity(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

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,9 +24,8 @@
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,122 +1,14 @@
/******************************
* 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
******************************/
.menubar .root-user .ui-button-text,
.menubar .root-user.ui-menu-item a {
.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;
color: black;
font-weight: bold;
}
.menubar .root-user.ui-menu-item a {
padding-left: 1em;
}

View file

@ -1,28 +1,18 @@
/******************************
* Filters
* Grid Filters
******************************/
div.filters form {
margin-bottom: 10px;
.filters .filter-fieldname .field,
.filters .filter-fieldname .field label {
width: 100%;
}
div.filters div.filter {
margin-bottom: 10px;
.filters .filter-fieldname .field label {
justify-content: left;
}
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;
.filters .filter-verb .select,
.filters .filter-verb .select select {
width: 100%;
}

View file

@ -1,34 +1,37 @@
/******************************
* Form Wrapper
* forms
******************************/
div.form-wrapper {
overflow: auto;
}
/******************************
* Forms
******************************/
div.form,
div.fieldset-form,
div.fieldset {
clear: left;
float: left;
margin-top: 10px;
}
/* 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%;
}
/******************************
* Fieldsets
* field-wrappers
******************************/
/* TODO: replace this with bulma equivalent */
.field-wrapper {
clear: both;
min-height: 30px;
@ -36,16 +39,12 @@ div.fieldset {
margin: 15px;
}
.field-wrapper.with-error {
background-color: #ddcccc;
border: 2px solid #dd6666;
padding-bottom: 1em;
}
/* 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;
@ -55,47 +54,8 @@ div.fieldset {
white-space: nowrap;
}
.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;
}
/* TODO: replace this with bulma equivalent */
.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,6 +25,11 @@
margin: 0;
}
.grid-tools {
display: flex;
gap: 0.5rem;
}
.grid-wrapper .grid-header td.tools {
margin: 0;
padding: 0;
@ -261,6 +266,10 @@
* main actions
******************************/
a.grid-action {
white-space: nowrap;
}
.grid .actions {
width: 1px;
}

View file

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

View file

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

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

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

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

View file

@ -1,57 +0,0 @@
/*
* 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,152 +1,87 @@
/******************************
* Main Layout
* main layout
******************************/
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;
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.content-wrapper {
height: 100%;
padding-bottom: 30px;
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
}
#scrollpane {
height: 100%;
/******************************
* header
******************************/
/* this is the one in the very top left of screen, next to logo and linked to
the home page */
#global-header-title {
margin-left: 0.3rem;
}
#scrollpane .inner-content {
padding: 0 0.5em 0.5em 0.5em;
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;
}
#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 {
list-style-type: none;
margin: 0.5em;
margin-bottom: 1em;
margin-left: 1em;
text-align: right;
white-space: nowrap;
}
@ -155,11 +90,24 @@ body > #body-wrapper {
* "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;
min-width: 20em;
width: 20em;
}
.object-helper-content {
@ -167,87 +115,44 @@ body > #body-wrapper {
}
/******************************
* Panels
* markdown
******************************/
.panel,
.panel-grid {
border-left: 1px solid Black;
margin-bottom: 1em;
.rendered-markdown p,
.rendered-markdown ul {
margin-bottom: 1rem;
}
.panel {
border-bottom: 1px solid Black;
border-right: 1px solid Black;
padding: 0px;
.rendered-markdown .codehilite {
margin-bottom: 2rem;
}
.panel h2,
.panel-grid h2 {
border-bottom: 1px solid Black;
border-top: 1px solid Black;
padding: 5px;
margin: 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-grid h2 {
border-right: 1px solid Black;
.modal-card-body {
overflow: visible !important;
}
.panel-body {
overflow: auto;
padding: 5px;
}
/* TODO: a simpler option we might try sometime instead? */
/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */
/****************************************
* 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%;
}
/* .dropdown-content{ */
/* position: fixed; */
/* } */
/******************************
* feedback
******************************/
#feedback-dialog {
display: none;
}
#feedback-dialog p {
margin-top: 1em;
}
#feedback-dialog .red {
.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

@ -1,48 +0,0 @@
/******************************
* 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;
}

View file

@ -1,452 +0,0 @@
/**********************************************************************
* 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

@ -1,10 +0,0 @@
/**
* 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

@ -1,331 +0,0 @@
/*
* 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

View file

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

@ -1,32 +0,0 @@
$(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

@ -1,29 +0,0 @@
/************************************************************
*
* 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

@ -1,41 +0,0 @@
/************************************************************
*
* 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="value ? parseDate(value) : null"',
':value="buefyValue"',
'@input="dateChanged"',
':disabled="disabled"',
'ref="trueDatePicker"',
@ -26,6 +26,18 @@ const TailboneDatepicker = {
disabled: Boolean,
},
data() {
return {
buefyValue: this.parseDate(this.value),
}
},
watch: {
value(to, from) {
this.buefyValue = this.parseDate(to)
},
},
methods: {
formatDate(date) {
@ -43,9 +55,12 @@ const TailboneDatepicker = {
},
parseDate(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])
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
},
dateChanged(date) {

View file

@ -1,156 +0,0 @@
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,15 +9,55 @@ 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
}
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)
},
},
}
Vue.component('tailbone-timepicker', TailboneTimepicker)

View file

@ -1,193 +0,0 @@
/************************************************************
*
* 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,58 +1,55 @@
$(function() {
let FeedbackForm = {
props: ['action', 'message'],
template: '#feedback-template',
mixins: [FormPosterMixin],
methods: {
$('#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) {
pleaseReplyChanged(value) {
this.$nextTick(() => {
this.$refs.userEmail.focus()
})
},
var msg = $.trim(textarea.val());
if (! msg) {
alert("Please enter a message.");
textarea.select();
textarea.focus();
return;
}
showFeedback() {
this.referrer = location.href
this.showDialog = true
this.$nextTick(function() {
this.$refs.textarea.focus()
})
},
disable_button(dialog_button(event));
sendFeedback() {
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
};
let params = {
referrer: this.referrer,
user: this.userUUID,
user_name: this.userName,
please_reply_to: this.pleaseReply ? this.userEmail : null,
message: this.message.trim(),
}
$.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.submitForm(this.action, params, response => {
}
},
{
text: "Cancel",
click: function() {
dialog.dialog('close');
}
}
]
});
});
});
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

@ -1,386 +0,0 @@
/************************************************************
*
* 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

@ -1,267 +0,0 @@
/************************************************************
*
* 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

@ -1,14 +0,0 @@
/******************************
* 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

@ -1,22 +0,0 @@
/******************************
* 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

@ -1,61 +0,0 @@
/******************************
* 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

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

View file

@ -1,133 +0,0 @@
/******************************
* 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

@ -1,54 +0,0 @@
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-2022 Lance Edgar
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,11 +24,10 @@
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
@ -37,147 +36,169 @@ 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.menus import make_simple_menus
from tailbone.util import should_use_buefy
from tailbone.util import get_available_themes, get_global_search_options
def new_request(event):
log = logging.getLogger(__name__)
def new_request(event, session=None):
"""
Identify the current user, and cache their current permissions. Also adds
the ``rattail_config`` attribute to the request.
Event hook called when processing a new request.
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']
This first invokes the upstream hooks:
This function merely "promotes" that config object so that it is more
directly accessible, a la::
* :func:`wuttaweb:wuttaweb.subscribers.new_request()`
* :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()`
request.rattail_config
It then adds more things to the request object; among them:
.. 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.
.. 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.
"""
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
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
# invoke main upstream logic
# nb. this sets request.wutta_config
base.new_request(event)
request.set_property(user, reify=True)
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)
# assign client IP address to the session, for sake of versioning
Session().continuum_remote_addr = request.client_addr
if hasattr(request, 'client_addr'):
session.continuum_remote_addr = request.client_addr
request.is_admin = bool(request.user) and request.user.is_admin()
request.is_root = request.is_admin and request.session.get('is_root', False)
# 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()
if rattail_config:
app = rattail_config.get_app()
auth = app.get_auth_handler()
request.tailbone_cached_permissions = auth.get_permissions(
Session(), request.user)
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
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()
rattail_config = request.rattail_config
config = request.wutta_config
app = config.get_app()
renderer_globals = event
renderer_globals['rattail_app'] = request.rattail_config.get_app()
renderer_globals['app_title'] = request.rattail_config.app_title()
# overrides
renderer_globals['h'] = helpers
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
# misc.
renderer_globals['datetime'] = datetime
renderer_globals['colander'] = colander
renderer_globals['deform'] = deform
renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config)
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
# 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 = request.rattail_config.getbool('tailbone', 'themes.expose_picker',
default=False)
expose_picker = config.get_bool('tailbone.themes.expose_picker',
default=False)
renderer_globals['expose_theme_picker'] = expose_picker
if expose_picker:
# 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]
# TODO: should remove 'falafel' option altogether
available = get_available_themes(config)
options = [tags.Option(theme, value=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'] = request.rattail_config.getbool(
'tailbone', 'messaging.enabled', default=False)
renderer_globals['messaging_enabled'] = config.get_bool('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'] = request.rattail_config.get(
'tailbone', 'background_color')
renderer_globals['background_color'] = config.get('tailbone.background_color')
# 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')
# maybe set custom stylesheet
css = None
if request.user:
css = config.get(f'tailbone.{request.user.uuid}', 'user_css')
if not css:
css = request.rattail_config.get('tailbone', 'theme.falafel.buefy_css')
renderer_globals['buefy_css'] = 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)
# here we globally declare widths for grid filter pseudo-columns
widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths')
widths = config.get('tailbone.grids.filters.column_widths')
if widths:
widths = widths.split(';')
if len(widths) < 2:
@ -188,7 +209,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(rattail_config)
renderer_globals['expose_websockets'] = should_expose_websockets(config)
def add_inbox_count(event):
@ -202,8 +223,9 @@ 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))\
@ -213,51 +235,14 @@ def add_inbox_count(event):
def context_found(event):
"""
Attach some goodies to the request object.
Attach some more goodies to the request object:
The following is attached to the request:
* 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()``.
* ``get_session_timeout()`` function
"""
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

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

View file

@ -0,0 +1,31 @@
## -*- 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,34 +5,6 @@
<%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>
@ -43,8 +15,8 @@
<app-settings :groups="groups" :showing-group="showingGroup"></app-settings>
</%def>
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
<script type="text/x-template" id="app-settings-template">
<div class="form">
@ -178,19 +150,18 @@
</script>
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
ThisPageData.groups = ${json.dumps(buefy_data)|n}
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.groups = ${json.dumps(settings_data)|n}
ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
</script>
</%def>
<%def name="make_this_page_component()">
${parent.make_this_page_component()}
<script type="text/javascript">
<%def name="make_vue_components()">
${parent.make_vue_components()}
<script>
Vue.component('app-settings', {
template: '#app-settings-template',
@ -221,78 +192,3 @@
</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,63 +1,5 @@
## -*- 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,8 +1,7 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/base_meta.mako" />
<%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="app_title()">${app.get_node_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'))}" />
@ -11,9 +10,3 @@
<%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_buefy_form()">
<%def name="render_form()">
<div class="form">
<tailbone-form></tailbone-form>
<br />
@ -76,12 +76,5 @@
</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