Compare commits

..

320 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
249 changed files with 14546 additions and 7017 deletions

3
.gitignore vendored
View file

@ -1,5 +1,8 @@
*~
*.pyc
.coverage .coverage
.tox/ .tox/
dist/
docs/_build/ docs/_build/
htmlcov/ htmlcov/
Tailbone.egg-info/ 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 Tailbone is an extensible web application based on Rattail. It provides a
"back-office network environment" (BONE) for use in managing retail data. "back-office network environment" (BONE) for use in managing retail data.
Please see Rattail's `home page`_ for more information. Please see Rattail's [home page](http://rattailproject.org/) for more
information.
.. _home page: http://rattailproject.org/

View file

@ -2,8 +2,55 @@
CHANGELOG CHANGELOG
========= =========
Unreleased NB. this file contains "old" release notes only. for newer releases
---------- see the `CHANGELOG.md` file in the source root folder.
0.9.96 (2024-04-25)
-------------------
* Remove unused code for ``webhelpers2_grid``.
* Rename setting for custom user css (remove "buefy").
* Fix permission checks for root user with pyramid 2.x.
* Cleanup grid/filters logic a bit.
* Use normal (not checkbox) button for grid filters.
* Tweak icon for Download Results button.
* Use v-model to track selection etc. for download results fields.
* Allow deleting rows from executed batches.
0.9.95 (2024-04-19)
-------------------
* Fix ASGI websockets when serving on sub-path under site root.
* Fix raw query to avoid SQLAlchemy 2.x warnings.
* Remove config "style" from appinfo page.
0.9.94 (2024-04-16)
-------------------
* Fix master template bug when no form in context.
0.9.93 (2024-04-16)
-------------------
* Improve form support for view supplements.
* Prevent multi-click for grid filters "Save Defaults" button.
* Fix typo when getting app instance.
0.9.92 (2024-04-16) 0.9.92 (2024-04-16)
------------------- -------------------
@ -4945,7 +4992,7 @@ and related technologies.
0.6.47 (2017-11-08) 0.6.47 (2017-11-08)
------------------- -------------------
* Fix manifest to include *.pt deform templates * Fix manifest to include ``*.pt`` deform templates
0.6.46 (2017-11-08) 0.6.46 (2017-11-08)
@ -5278,13 +5325,13 @@ and related technologies.
0.6.13 (2017-07-26) 0.6.13 (2017-07-26)
------------------ -------------------
* Allow master view to decide whether each grid checkbox is checked * Allow master view to decide whether each grid checkbox is checked
0.6.12 (2017-07-26) 0.6.12 (2017-07-26)
------------------ -------------------
* Add basic support for product inventory and status * Add basic support for product inventory and status
@ -5292,7 +5339,7 @@ and related technologies.
0.6.11 (2017-07-18) 0.6.11 (2017-07-18)
------------------ -------------------
* Tweak some basic styles for forms/grids * Tweak some basic styles for forms/grids
@ -5300,7 +5347,7 @@ and related technologies.
0.6.10 (2017-07-18) 0.6.10 (2017-07-18)
------------------ -------------------
* Fix grid bug if "current page" becomes invalid * Fix grid bug if "current page" becomes invalid

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

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

View file

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

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

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

View file

@ -44,6 +44,7 @@ Package API:
api/api/batch/core api/api/batch/core
api/api/batch/ordering api/api/batch/ordering
api/db
api/diffs api/diffs
api/forms api/forms
api/forms.widgets api/forms.widgets
@ -51,6 +52,7 @@ Package API:
api/grids.core api/grids.core
api/progress api/progress
api/subscribers api/subscribers
api/util
api/views/batch api/views/batch
api/views/batch.vendorcatalog api/views/batch.vendorcatalog
api/views/core api/views/core
@ -60,6 +62,14 @@ Package API:
api/views/purchasing.ordering api/views/purchasing.ordering
Changelog:
.. toctree::
:maxdepth: 1
changelog
Documentation To-Do 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"

112
setup.cfg
View file

@ -1,112 +0,0 @@
# -*- coding: utf-8; -*-
[nosetests]
nocapture = 1
cover-package = tailbone
cover-erase = 1
cover-html = 1
cover-html-dir = htmlcov
[metadata]
name = Tailbone
version = attr: tailbone.__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 = file: README.rst
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.6
Programming Language :: Python :: 3.7
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
[options]
install_requires =
# 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
# TODO: remove once their bug is fixed? idk what this is about yet...
deform<2.0.15
asgiref
colander
ColanderAlchemy
cornice
cornice-swagger
humanize
Mako
markdown
openpyxl
paginate
paginate_sqlalchemy
passlib
Pillow
pyramid
pyramid_beaker>=0.6
pyramid_deform
pyramid_exclog
pyramid_mako
pyramid_retry
pyramid_tm
rattail[db,bouncer]
six
sa-filters
simplejson
transaction
waitress
WebHelpers2
zope.sqlalchemy
tests_require = Tailbone[tests]
test_suite = nose.collector
packages = find:
include_package_data = True
zip_safe = False
[options.packages.find]
exclude =
tests.*
tests
[options.extras_require]
docs = Sphinx; sphinx-rtd-theme
tests = coverage; fixture; mock; nose; pytest; pytest-cov
[options.entry_points]
paste.app_factory =
main = tailbone.app:main
webapi = tailbone.webapi:main
rattail.cleaners =
beaker = tailbone.cleanup:BeakerCleaner
rattail.config.extensions =
tailbone = tailbone.config:ConfigExtension
pyramid.scaffold =
rattail = tailbone.scaffolds:RattailTemplate

View file

@ -1,3 +1,9 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
__version__ = '0.9.92' 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 # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,8 +24,6 @@
Tailbone Web API - Auth Views Tailbone Web API - Auth Views
""" """
from rattail.db.auth import set_user_password
from cornice import Service from cornice import Service
from tailbone.api import APIView, api from tailbone.api import APIView, api
@ -42,11 +40,10 @@ class AuthenticationView(APIView):
This will establish a server-side web session for the user if none This will establish a server-side web session for the user if none
exists. Note that this also resets the user's session timer. exists. Note that this also resets the user's session timer.
""" """
data = {'ok': True} data = {'ok': True, 'permissions': []}
if self.request.user: if self.request.user:
data['user'] = self.get_user_info(self.request.user) data['user'] = self.get_user_info(self.request.user)
data['permissions'] = list(self.request.user_permissions)
data['permissions'] = list(self.request.tailbone_cached_permissions)
# background color may be set per-request, by some apps # background color may be set per-request, by some apps
if hasattr(self.request, 'background_color') and self.request.background_color: if hasattr(self.request, 'background_color') and self.request.background_color:
@ -176,7 +173,8 @@ class AuthenticationView(APIView):
return {'error': "The current/old password you provided is incorrect"} return {'error': "The current/old password you provided is incorrect"}
# okay then, set new password # 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 { return {
'ok': True, 'ok': True,
'user': self.get_user_info(self.request.user), 'user': self.get_user_info(self.request.user),

View file

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

View file

@ -86,6 +86,8 @@ class OrderingBatchViews(APIBatchView):
Sets the mode to "ordering" for the new batch. Sets the mode to "ordering" for the new batch.
""" """
data = dict(data) data = dict(data)
if not data.get('vendor_uuid'):
raise ValueError("You must specify the vendor")
data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
batch = super().create_object(data) batch = super().create_object(data)
return batch return batch

View file

@ -29,8 +29,7 @@ import logging
import humanize import humanize
import sqlalchemy as sa import sqlalchemy as sa
from rattail.db import model from rattail.db.model import PurchaseBatch, PurchaseBatchRow
from rattail.util import pretty_quantity
from cornice import Service from cornice import Service
from deform import widget as dfwidget from deform import widget as dfwidget
@ -45,7 +44,7 @@ log = logging.getLogger(__name__)
class ReceivingBatchViews(APIBatchView): class ReceivingBatchViews(APIBatchView):
model_class = model.PurchaseBatch model_class = PurchaseBatch
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receivingbatchviews' route_prefix = 'receivingbatchviews'
permission_prefix = 'receiving' permission_prefix = 'receiving'
@ -55,7 +54,8 @@ class ReceivingBatchViews(APIBatchView):
supports_execute = True supports_execute = True
def base_query(self): 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) query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
return query return query
@ -85,7 +85,7 @@ class ReceivingBatchViews(APIBatchView):
# assume "receive from PO" if given a PO key # assume "receive from PO" if given a PO key
if data.get('purchase_key'): if data.get('purchase_key'):
data['receiving_workflow'] = 'from_po' data['workflow'] = 'from_po'
return super().create_object(data) return super().create_object(data)
@ -120,6 +120,7 @@ class ReceivingBatchViews(APIBatchView):
return self._get(obj=batch) return self._get(obj=batch)
def eligible_purchases(self): def eligible_purchases(self):
model = self.app.model
uuid = self.request.params.get('vendor_uuid') uuid = self.request.params.get('vendor_uuid')
vendor = self.Session.get(model.Vendor, uuid) if uuid else None vendor = self.Session.get(model.Vendor, uuid) if uuid else None
if not vendor: if not vendor:
@ -176,7 +177,7 @@ class ReceivingBatchViews(APIBatchView):
class ReceivingBatchRowViews(APIBatchRowView): class ReceivingBatchRowViews(APIBatchRowView):
model_class = model.PurchaseBatchRow model_class = PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
route_prefix = 'receiving.rows' route_prefix = 'receiving.rows'
permission_prefix = 'receiving' permission_prefix = 'receiving'
@ -185,7 +186,8 @@ class ReceivingBatchRowViews(APIBatchRowView):
supports_quick_entry = True supports_quick_entry = True
def make_filter_spec(self): def make_filter_spec(self):
filters = super(ReceivingBatchRowViews, self).make_filter_spec() model = self.app.model
filters = super().make_filter_spec()
if filters: if filters:
# must translate certain convenience filters # must translate certain convenience filters
@ -296,11 +298,11 @@ class ReceivingBatchRowViews(APIBatchRowView):
return filters return filters
def normalize(self, row): def normalize(self, row):
data = super(ReceivingBatchRowViews, self).normalize(row) data = super().normalize(row)
model = self.app.model
batch = row.batch batch = row.batch
app = self.get_rattail_app() prodder = self.app.get_products_handler()
prodder = app.get_products_handler()
data['product_uuid'] = row.product_uuid data['product_uuid'] = row.product_uuid
data['item_id'] = row.item_id data['item_id'] = row.item_id
@ -375,7 +377,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
if accounted_for: if accounted_for:
# some product accounted for; button should receive "remainder" only # some product accounted for; button should receive "remainder" only
if remainder: if remainder:
remainder = pretty_quantity(remainder) remainder = self.app.render_quantity(remainder)
data['quick_receive_quantity'] = remainder data['quick_receive_quantity'] = remainder
data['quick_receive_text'] = "Receive Remainder ({} {})".format( data['quick_receive_text'] = "Receive Remainder ({} {})".format(
remainder, data['unit_uom']) remainder, data['unit_uom'])
@ -386,7 +388,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
else: # nothing yet accounted for, button should receive "all" else: # nothing yet accounted for, button should receive "all"
if not remainder: if not remainder:
log.warning("quick receive remainder is empty for row %s", row.uuid) 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_quantity'] = remainder
data['quick_receive_text'] = "Receive ALL ({} {})".format( data['quick_receive_text'] = "Receive ALL ({} {})".format(
remainder, data['unit_uom']) remainder, data['unit_uom'])
@ -414,7 +416,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
data['received_alert'] = None data['received_alert'] = None
if self.batch_handler.get_units_confirmed(row): if self.batch_handler.get_units_confirmed(row):
msg = "You have already received some of this product; last update was {}.".format( 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 data['received_alert'] = msg
return data return data
@ -423,6 +425,8 @@ class ReceivingBatchRowViews(APIBatchRowView):
""" """
View which handles "receiving" against a particular batch row. View which handles "receiving" against a particular batch row.
""" """
model = self.app.model
# first do basic input validation # first do basic input validation
schema = ReceiveRow().bind(session=self.Session()) schema = ReceiveRow().bind(session=self.Session())
form = forms.Form(schema=schema, request=self.request) form = forms.Form(schema=schema, request=self.request)

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -26,15 +26,12 @@ Tailbone Web API - "Common" Views
from collections import OrderedDict from collections import OrderedDict
import rattail from rattail.util import get_pkg_version
from rattail.db import model
from rattail.mail import send_email
from cornice import Service from cornice import Service
from cornice.service import get_services from cornice.service import get_services
from cornice_swagger import CorniceSwagger from cornice_swagger import CorniceSwagger
import tailbone
from tailbone import forms from tailbone import forms
from tailbone.forms.common import Feedback from tailbone.forms.common import Feedback
from tailbone.api import APIView, api from tailbone.api import APIView, api
@ -66,11 +63,12 @@ class CommonView(APIView):
} }
def get_project_title(self): 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): def get_project_version(self):
import tailbone app = self.get_rattail_app()
return tailbone.__version__ return app.get_version()
def get_packages(self): def get_packages(self):
""" """
@ -78,8 +76,8 @@ class CommonView(APIView):
'about' page. 'about' page.
""" """
return OrderedDict([ return OrderedDict([
('rattail', rattail.__version__), ('rattail', get_pkg_version('rattail')),
('Tailbone', tailbone.__version__), ('Tailbone', get_pkg_version('Tailbone')),
]) ])
@api @api
@ -87,6 +85,8 @@ class CommonView(APIView):
""" """
View to handle user feedback form submits. 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 # TODO: this logic was copied from tailbone.views.common and is largely
# identical; perhaps should merge somehow? # identical; perhaps should merge somehow?
schema = Feedback().bind(session=Session()) schema = Feedback().bind(session=Session())
@ -106,7 +106,7 @@ class CommonView(APIView):
data['client_ip'] = self.request.client_addr data['client_ip'] = self.request.client_addr
email_key = data['email_key'] or self.feedback_email_key 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 {'ok': True}
return {'error': "Form did not validate!"} return {'error': "Form did not validate!"}

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -102,7 +102,7 @@ class APIView(View):
auth = app.get_auth_handler() auth = app.get_auth_handler()
# basic / default info # basic / default info
is_admin = user.is_admin() is_admin = auth.user_is_admin(user)
employee = app.get_employee(user) employee = app.get_employee(user)
info = { info = {
'uuid': user.uuid, 'uuid': user.uuid,

View file

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

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -26,12 +26,11 @@ Tailbone Web API - Master View
import json import json
from rattail.config import parse_bool
from rattail.db.util import get_fieldnames from rattail.db.util import get_fieldnames
from cornice import resource, Service from cornice import resource, Service
from tailbone.api import APIView, api from tailbone.api import APIView
from tailbone.db import Session from tailbone.db import Session
from tailbone.util import SortColumn from tailbone.util import SortColumn
@ -185,7 +184,7 @@ class APIMasterView(APIView):
if sortcol: if sortcol:
spec = { spec = {
'field': sortcol.field_name, '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: if sortcol.model_name:
spec['model'] = sortcol.model_name spec['model'] = sortcol.model_name
@ -355,9 +354,13 @@ class APIMasterView(APIView):
data = self.request.json_body data = self.request.json_body
# add instance to session, and return data for it # add instance to session, and return data for it
obj = self.create_object(data) try:
self.Session.flush() obj = self.create_object(data)
return self._get(obj) except Exception as error:
return self.json_response({'error': str(error)})
else:
self.Session.flush()
return self._get(obj)
def create_object(self, data): def create_object(self, data):
""" """

View file

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

View file

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

View file

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

View file

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

View file

@ -25,21 +25,19 @@ Application Entry Point
""" """
import os import os
import warnings
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker, scoped_session 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.exceptions import ConfigurationError
from rattail.db.types import GPCType
from pyramid.config import Configurator from pyramid.config import Configurator
from pyramid.authentication import SessionAuthenticationPolicy
from zope.sqlalchemy import register from zope.sqlalchemy import register
import tailbone.db 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.config import csrf_token_name, csrf_header_name
from tailbone.util import get_effective_theme, get_theme_template_path from tailbone.util import get_effective_theme, get_theme_template_path
from tailbone.providers import get_all_providers from tailbone.providers import get_all_providers
@ -61,9 +59,23 @@ def make_rattail_config(settings):
rattail_config = make_config(path) rattail_config = make_config(path)
settings['rattail_config'] = rattail_config settings['rattail_config'] = rattail_config
# nb. this is for compaibility with wuttaweb
settings['wutta_config'] = rattail_config
# must import all sqlalchemy models before things get rolling,
# otherwise can have errors about continuum TransactionMeta class
# not yet mapped, when relevant pages are first requested...
# cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models
# hat tip to https://stackoverflow.com/a/59241485
if getattr(rattail_config, 'tempmon_engine', None):
from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
tempmon_session = TempmonSession()
tempmon_session.query(tempmon_model.Appliance).first()
tempmon_session.close()
# configure database sessions # configure database sessions
if hasattr(rattail_config, 'rattail_engine'): if hasattr(rattail_config, 'appdb_engine'):
tailbone.db.Session.configure(bind=rattail_config.rattail_engine) tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
if hasattr(rattail_config, 'trainwreck_engine'): if hasattr(rattail_config, 'trainwreck_engine'):
tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
if hasattr(rattail_config, 'tempmon_engine'): if hasattr(rattail_config, 'tempmon_engine'):
@ -123,24 +135,21 @@ def make_pyramid_config(settings, configure_csrf=True):
config.set_root_factory(Root) config.set_root_factory(Root)
else: else:
# declare this web app of the "classic" variety
settings.setdefault('tailbone.classic', 'true')
# we want the new themes feature! # we want the new themes feature!
establish_theme(settings) establish_theme(settings)
settings.setdefault('fanstatic.versioning', 'true')
settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform') settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
config = Configurator(settings=settings, root_factory=Root) config = Configurator(settings=settings, root_factory=Root)
# add rattail config directly to registry # add rattail config directly to registry, for access throughout the app
config.registry['rattail_config'] = rattail_config config.registry['rattail_config'] = rattail_config
# configure user authorization / authentication # configure user authorization / authentication
# TODO: security policy should become the default, for pyramid 2.x config.set_security_policy(TailboneSecurityPolicy())
if rattail_config.getbool('tailbone', 'pyramid.use_security_policy',
usedb=False, default=False):
from tailbone.auth import TailboneSecurityPolicy
config.set_security_policy(TailboneSecurityPolicy())
else:
config.set_authorization_policy(TailboneAuthorizationPolicy())
config.set_authentication_policy(SessionAuthenticationPolicy())
# maybe require CSRF token protection # maybe require CSRF token protection
if configure_csrf: if configure_csrf:
@ -151,6 +160,7 @@ def make_pyramid_config(settings, configure_csrf=True):
# Bring in some Pyramid goodies. # Bring in some Pyramid goodies.
config.include('tailbone.beaker') config.include('tailbone.beaker')
config.include('pyramid_deform') config.include('pyramid_deform')
config.include('pyramid_fanstatic')
config.include('pyramid_mako') config.include('pyramid_mako')
config.include('pyramid_tm') config.include('pyramid_tm')
@ -186,9 +196,16 @@ def make_pyramid_config(settings, configure_csrf=True):
for spec in includes: for spec in includes:
config.include(spec) config.include(spec)
# Add some permissions magic. # add some permissions magic
config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') config.add_directive('add_wutta_permission_group',
config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') '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 # 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_index_page', 'tailbone.app.add_index_page')
@ -315,7 +332,8 @@ def main(global_config, **settings):
""" """
This function returns a Pyramid WSGI application. This function returns a Pyramid WSGI application.
""" """
settings.setdefault('mako.directories', ['tailbone:templates']) settings.setdefault('mako.directories', ['tailbone:templates',
'wuttaweb:templates'])
rattail_config = make_rattail_config(settings) rattail_config = make_rattail_config(settings)
pyramid_config = make_pyramid_config(settings) pyramid_config = make_pyramid_config(settings)
pyramid_config.include('tailbone') pyramid_config.include('tailbone')

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,14 +24,10 @@
ASGI App Utilities ASGI App Utilities
""" """
from __future__ import unicode_literals, absolute_import
import os import os
import configparser
import logging import logging
import six
from six.moves import configparser
from rattail.util import load_object from rattail.util import load_object
from asgiref.wsgi import WsgiToAsgi from asgiref.wsgi import WsgiToAsgi
@ -49,6 +45,12 @@ class TailboneWsgiToAsgi(WsgiToAsgi):
protocol = scope['type'] protocol = scope['type']
path = scope['path'] 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': if protocol == 'websocket':
websockets = self.wsgi_application.registry.get( websockets = self.wsgi_application.registry.get(
'tailbone_websockets', {}) 'tailbone_websockets', {})
@ -85,7 +87,7 @@ def make_asgi_app(main_app=None):
# parse the settings needed for pyramid app # parse the settings needed for pyramid app
settings = dict(parser.items('app:main')) 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) make_wsgi_app = load_object(main_app)
elif callable(main_app): elif callable(main_app):
make_wsgi_app = main_app make_wsgi_app = main_app

View file

@ -27,29 +27,28 @@ Authentication & Authorization
import logging import logging
import re import re
from rattail.util import prettify, NOTSET from wuttjamaican.util import UNSPECIFIED
from zope.interface import implementer from pyramid.security import remember, forget
from pyramid.interfaces import IAuthorizationPolicy
from pyramid.security import remember, forget, Everyone, Authenticated
from pyramid.authentication import SessionAuthenticationPolicy
from wuttaweb.auth import WuttaSecurityPolicy
from tailbone.db import Session from tailbone.db import Session
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def login_user(request, user, timeout=NOTSET): def login_user(request, user, timeout=UNSPECIFIED):
""" """
Perform the steps necessary to login the given user. Note that this Perform the steps necessary to login the given user. Note that this
returns a ``headers`` dict which you should pass to the redirect. returns a ``headers`` dict which you should pass to the redirect.
""" """
app = request.rattail_config.get_app() config = request.rattail_config
app = config.get_app()
user.record_event(app.enum.USER_EVENT_LOGIN) user.record_event(app.enum.USER_EVENT_LOGIN)
headers = remember(request, user.uuid) headers = remember(request, user.uuid)
if timeout is NOTSET: if timeout is UNSPECIFIED:
timeout = session_timeout_for_user(user) timeout = session_timeout_for_user(config, user)
log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
set_session_timeout(request, timeout) set_session_timeout(request, timeout)
return headers return headers
@ -70,15 +69,18 @@ def logout_user(request):
return headers 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 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 timeouts = [role.session_timeout for role in roles
if role.session_timeout is not None] if role.session_timeout is not None]
if timeouts and 0 not in timeouts: if timeouts and 0 not in timeouts:
return max(timeouts) return max(timeouts)
@ -90,76 +92,12 @@ def set_session_timeout(request, timeout):
request.session['_timeout'] = timeout or None request.session['_timeout'] = timeout or None
class TailboneAuthenticationPolicy(SessionAuthenticationPolicy): class TailboneSecurityPolicy(WuttaSecurityPolicy):
"""
Custom authentication policy for Tailbone.
This is mostly Pyramid's built-in session-based policy, but adds
logic to accept Rattail User API Tokens in lieu of current user
being identified via the session.
Note that the traditional Tailbone web app does *not* use this
policy, only the Tailbone web API uses it by default.
"""
def unauthenticated_userid(self, request):
# figure out userid 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)
rattail_config = request.registry.settings.get('rattail_config')
app = rattail_config.get_app()
auth = app.get_auth_handler()
user = auth.authenticate_user_token(Session(), token)
if user:
return user.uuid
# otherwise do normal session-based logic
return super().unauthenticated_userid(request)
@implementer(IAuthorizationPolicy)
class TailboneAuthorizationPolicy(object):
def permits(self, context, principals, permission):
config = context.request.rattail_config
model = config.get_model()
app = config.get_app()
auth = app.get_auth_handler()
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.get(model.User, 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
def principals_allowed_by_permission(self, context, permission):
raise NotImplementedError
class TailboneSecurityPolicy:
def __init__(self, api_mode=False):
from pyramid.authentication import SessionAuthenticationHelper
from pyramid.request import RequestLocalCache
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 self.api_mode = api_mode
self.session_helper = SessionAuthenticationHelper()
self.identity_cache = RequestLocalCache(self.load_identity)
def load_identity(self, request): def load_identity(self, request):
config = request.registry.settings.get('rattail_config') config = request.registry.settings.get('rattail_config')
@ -175,7 +113,7 @@ class TailboneSecurityPolicy:
if match: if match:
token = match.group(1) token = match.group(1)
auth = app.get_auth_handler() auth = app.get_auth_handler()
user = auth.authenticate_user_token(Session(), token) user = auth.authenticate_user_token(self.db_session, token)
if not user: if not user:
@ -186,59 +124,10 @@ class TailboneSecurityPolicy:
# fetch user object from db # fetch user object from db
model = app.model model = app.model
user = Session.get(model.User, uuid) user = self.db_session.get(model.User, uuid)
if not user: if not user:
return return
# this user is responsible for data changes in current request # this user is responsible for data changes in current request
Session().set_continuum_user(user) self.db_session.set_continuum_user(user)
return user return user
def identity(self, request):
return self.identity_cache.get_or_create(request)
def authenticated_userid(self, request):
user = self.identity(request)
if user is not None:
return user.uuid
def remember(self, request, userid, **kw):
return self.session_helper.remember(request, userid, **kw)
def forget(self, request, **kw):
return self.session_helper.forget(request, **kw)
def permits(self, request, context, permission):
config = request.registry.settings.get('rattail_config')
app = config.get_app()
auth = app.get_auth_handler()
user = self.identity(request)
return auth.has_permission(Session(), user, permission)
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)
def add_permission(config, groupkey, key, label=None):
"""
Add a permission to the app configuration.
"""
def action():
perms = config.get_settings().get('tailbone_permissions', {})
group = perms.setdefault(groupkey, {'key': groupkey})
group.setdefault('label', prettify(groupkey))
perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
perm['label'] = label or prettify(key)
config.add_settings({'tailbone_permissions': perms})
config.action(None, action)

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # 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. pyramid_beaker projects.
""" """
from __future__ import unicode_literals, absolute_import
import time import time
from pkg_resources import parse_version from pkg_resources import parse_version
from rattail.util import get_pkg_version
import beaker import beaker
from beaker.session import Session from beaker.session import Session
from beaker.util import coerce_session_params from beaker.util import coerce_session_params
@ -49,7 +49,7 @@ class TailboneSession(Session):
"Loads the data from this session from persistent storage" "Loads the data from this session from persistent storage"
# are we using older version of beaker? # 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, self.namespace = self.namespace_class(self.id,
data_dir=self.data_dir, data_dir=self.data_dir,

View file

@ -26,13 +26,14 @@ Rattail config extension for Tailbone
import warnings import warnings
from rattail.config import ConfigExtension as BaseExtension from wuttjamaican.conf import WuttaConfigExtension
from rattail.db.config import configure_session from rattail.db.config import configure_session
from tailbone.db import Session from tailbone.db import Session
class ConfigExtension(BaseExtension): class ConfigExtension(WuttaConfigExtension):
""" """
Rattail config extension for Tailbone. Does the following: Rattail config extension for Tailbone. Does the following:
@ -49,9 +50,12 @@ class ConfigExtension(BaseExtension):
configure_session(config, Session) configure_session(config, Session)
# provide default theme selection # provide default theme selection
config.setdefault('tailbone', 'themes.keys', 'default, falafel') config.setdefault('tailbone', 'themes.keys', 'default, butterball')
config.setdefault('tailbone', 'themes.expose_picker', 'true') 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): def csrf_token_name(config):
return config.get('tailbone', 'csrf_token_name', default='_csrf') return config.get('tailbone', 'csrf_token_name', default='_csrf')

View file

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

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -270,9 +270,21 @@ class VersionDiff(Diff):
for field in self.fields: for field in self.fields:
values[field] = {'before': self.render_old_value(field), values[field] = {'before': self.render_old_value(field),
'after': self.render_new_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 { return {
'key': id(self.version), 'key': id(self.version),
'model_title': self.title, 'model_title': self.title,
'operation': operation,
'diff_class': self.nature, 'diff_class': self.nature,
'fields': self.fields, 'fields': self.fields,
'values': values, 'values': values,

View file

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

View file

@ -35,7 +35,7 @@ from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY
from wuttjamaican.util import UNSPECIFIED from wuttjamaican.util import UNSPECIFIED
from rattail.util import prettify, pretty_boolean from rattail.util import pretty_boolean
from rattail.db.util import get_fieldnames from rattail.db.util import get_fieldnames
import colander import colander
@ -47,12 +47,14 @@ from pyramid_deform import SessionFileUploadTempStore
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import tags, HTML from webhelpers2.html import tags, HTML
from wuttaweb.util import FieldList, get_form_data, make_json_safe
from tailbone.db import Session from tailbone.db import Session
from tailbone.util import raw_datetime, get_form_data, render_markdown from tailbone.util import raw_datetime, render_markdown
from tailbone.forms import types from tailbone.forms import types
from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget,
JQueryDateWidget, JQueryTimeWidget, JQueryDateWidget, JQueryTimeWidget,
MultiFileUploadWidget) FileUploadWidget, MultiFileUploadWidget)
from tailbone.exceptions import TailboneJSONFieldError from tailbone.exceptions import TailboneJSONFieldError
@ -326,7 +328,7 @@ class Form(object):
""" """
Base class for all forms. Base class for all forms.
""" """
save_label = "Save" save_label = "Submit"
update_label = "Save" update_label = "Save"
show_cancel = True show_cancel = True
auto_disable = True auto_disable = True
@ -337,10 +339,12 @@ class Form(object):
model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={},
assume_local_times=False, renderers=None, renderer_kwargs={}, assume_local_times=False, renderers=None, renderer_kwargs={},
hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
action_url=None, cancel_url=None, component='tailbone-form', action_url=None, cancel_url=None,
vuejs_component_kwargs=None, vuejs_field_converters={}, vue_tagname=None,
vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={},
# TODO: ugh this is getting out hand! # TODO: ugh this is getting out hand!
can_edit_help=False, edit_help_url=None, route_prefix=None, can_edit_help=False, edit_help_url=None, route_prefix=None,
**kwargs
): ):
self.fields = None self.fields = None
if fields is not None: if fields is not None:
@ -378,21 +382,79 @@ class Form(object):
self.focus_spec = focus_spec self.focus_spec = focus_spec
self.action_url = action_url self.action_url = action_url
self.cancel_url = cancel_url self.cancel_url = cancel_url
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_component_kwargs = vuejs_component_kwargs or {}
self.vuejs_field_converters = vuejs_field_converters 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.can_edit_help = can_edit_help
self.edit_help_url = edit_help_url self.edit_help_url = edit_help_url
self.route_prefix = route_prefix self.route_prefix = route_prefix
self.button_icon_submit = kwargs.get('button_icon_submit', 'save')
def __iter__(self): def __iter__(self):
return iter(self.fields) return iter(self.fields)
@property @property
def component_studly(self): def vue_component(self):
words = self.component.split('-') """
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]) 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): def __contains__(self, item):
return item in self.fields return item in self.fields
@ -568,7 +630,9 @@ class Form(object):
self.schema[key].title = label self.schema[key].title = label
def get_label(self, key): 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): def set_readonly(self, key, readonly=True):
if readonly: if readonly:
@ -644,7 +708,7 @@ class Form(object):
self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8))
elif type_ == 'file': elif type_ == 'file':
tmpstore = SessionFileUploadTempStore(self.request) tmpstore = SessionFileUploadTempStore(self.request)
kw = {'widget': dfwidget.FileUploadWidget(tmpstore), kw = {'widget': FileUploadWidget(tmpstore, request=self.request),
'title': self.get_label(key)} 'title': self.get_label(key)}
if 'required' in kwargs and not kwargs['required']: if 'required' in kwargs and not kwargs['required']:
kw['missing'] = colander.null kw['missing'] = colander.null
@ -799,6 +863,10 @@ class Form(object):
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
return self.render_deform(**kwargs) return self.render_deform(**kwargs)
def get_deform(self):
""" """
return self.make_deform_form()
def make_deform_form(self): def make_deform_form(self):
if not hasattr(self, 'deform_form'): if not hasattr(self, 'deform_form'):
@ -837,6 +905,11 @@ class Form(object):
return self.deform_form return self.deform_form
def render_vue_template(self, template='/forms/deform.mako', **context):
""" """
output = self.render_deform(template=template, **context)
return HTML.literal(output)
def render_deform(self, dform=None, template=None, **kwargs): def render_deform(self, dform=None, template=None, **kwargs):
if not template: if not template:
template = '/forms/deform.mako' template = '/forms/deform.mako'
@ -859,8 +932,8 @@ class Form(object):
context.setdefault('form_kwargs', {}) context.setdefault('form_kwargs', {})
# TODO: deprecate / remove the latter option here # TODO: deprecate / remove the latter option here
if self.auto_disable_save or self.auto_disable: if self.auto_disable_save or self.auto_disable:
context['form_kwargs'].setdefault('ref', self.component_studly) context['form_kwargs'].setdefault('ref', self.vue_component)
context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component)
if self.focus_spec: if self.focus_spec:
context['form_kwargs']['data-focus'] = self.focus_spec context['form_kwargs']['data-focus'] = self.focus_spec
context['request'] = self.request context['request'] = self.request
@ -872,11 +945,13 @@ class Form(object):
return dict([(field, self.get_label(field)) return dict([(field, self.get_label(field))
for field in self]) for field in self])
def get_field_markdowns(self): def get_field_markdowns(self, session=None):
model = self.request.rattail_config.get_model() app = self.request.rattail_config.get_app()
model = app.model
session = session or Session()
if not hasattr(self, 'field_markdowns'): if not hasattr(self, 'field_markdowns'):
infos = Session.query(model.TailboneFieldInfo)\ infos = session.query(model.TailboneFieldInfo)\
.filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\ .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\
.all() .all()
self.field_markdowns = dict([(info.field_name, info.markdown_text) self.field_markdowns = dict([(info.field_name, info.markdown_text)
@ -884,6 +959,18 @@ class Form(object):
return self.field_markdowns 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): def get_vuejs_model_value(self, field):
""" """
This method must return "raw" JS which will be assigned as the initial This method must return "raw" JS which will be assigned as the initial
@ -950,7 +1037,11 @@ class Form(object):
def set_vuejs_component_kwargs(self, **kwargs): def set_vuejs_component_kwargs(self, **kwargs):
self.vuejs_component_kwargs.update(kwargs) self.vuejs_component_kwargs.update(kwargs)
def render_vuejs_component(self): def render_vue_tag(self, **kwargs):
""" """
return self.render_vuejs_component(**kwargs)
def render_vuejs_component(self, **kwargs):
""" """
Render the Vue.js component HTML for the form. Render the Vue.js component HTML for the form.
@ -961,12 +1052,42 @@ class Form(object):
<tailbone-form :configure-fields-help="configureFieldsHelp"> <tailbone-form :configure-fields-help="configureFieldsHelp">
</tailbone-form> </tailbone-form>
""" """
kwargs = dict(self.vuejs_component_kwargs) kw = dict(self.vuejs_component_kwargs)
kw.update(kwargs)
if self.can_edit_help: if self.can_edit_help:
kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') kw.setdefault(':configure-fields-help', 'configureFieldsHelp')
return HTML.tag(self.component, **kwargs) return HTML.tag(self.vue_tagname, **kw)
def render_field_complete(self, fieldname, bfield_attrs={}): 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>`` Render the given field completely, i.e. with ``<b-field>``
wrapper. Note that this is meant to render *editable* fields, wrapper. Note that this is meant to render *editable* fields,
@ -984,7 +1105,7 @@ class Form(object):
if self.field_visible(fieldname): if self.field_visible(fieldname):
label = self.get_label(fieldname) label = self.get_label(fieldname)
markdowns = self.get_field_markdowns() markdowns = self.get_field_markdowns(session=session)
# these attrs will be for the <b-field> (*not* the widget) # these attrs will be for the <b-field> (*not* the widget)
attrs = { attrs = {
@ -1017,9 +1138,17 @@ class Form(object):
if field_type: if field_type:
attrs['type'] = field_type attrs['type'] = field_type
if messages: if messages:
attrs[':message'] = '[{}]'.format(', '.join([ if len(messages) == 1:
"'{}'".format(msg.replace("'", r"\'")) msg = messages[0]
for msg in messages])) 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 # merge anything caller provided
attrs.update(bfield_attrs) attrs.update(bfield_attrs)
@ -1067,15 +1196,27 @@ class Form(object):
label_contents.append(HTML.literal('&nbsp; &nbsp;')) label_contents.append(HTML.literal('&nbsp; &nbsp;'))
label_contents.append(icon) label_contents.append(icon)
# nb. must apply hack to get <template #label> as final result # only declare label template if it's complex
label_template = HTML.tag('template', c=label_contents, html = [html]
**{'#label': 1}) # TODO: figure out why complex label does not work for oruga
label_template = label_template.replace( if self.request.use_oruga:
HTML.literal('<template #label="1"'), attrs['label'] = label
HTML.literal('<template #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> # and finally wrap it all in a <b-field>
return HTML.tag('b-field', c=[label_template, html], **attrs) return HTML.tag('b-field', c=html, **attrs)
elif field: # hidden field elif field: # hidden field
@ -1083,6 +1224,18 @@ class Form(object):
# TODO: again, why does serialize() not return literal? # TODO: again, why does serialize() not return literal?
return HTML.literal(field.serialize()) 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): def render_field_readonly(self, field_name, **kwargs):
""" """
Render the given field completely, but in read-only fashion. Render the given field completely, but in read-only fashion.
@ -1093,20 +1246,30 @@ class Form(object):
if field_name not in self.fields: if field_name not in self.fields:
return '' return ''
# TODO: fair bit of duplication here, should merge with deform.mako
label = kwargs.get('label') label = kwargs.get('label')
if not label: if not label:
label = self.get_label(field_name) 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): value = self.render_field_value(field_name) or ''
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) 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): def render_field_value(self, field_name):
record = self.model_instance record = self.model_instance
@ -1130,7 +1293,7 @@ class Form(object):
value = self.obtain_value(record, field_name) value = self.obtain_value(record, field_name)
if value is None: if value is None:
return "" return ""
app = self.get_rattail_app() app = self.request.rattail_config.get_app()
value = app.localtime(value) value = app.localtime(value)
return raw_datetime(self.request.rattail_config, value) return raw_datetime(self.request.rattail_config, value)
@ -1160,7 +1323,7 @@ class Form(object):
value = self.obtain_value(obj, field) value = self.obtain_value(obj, field)
if value is None: if value is None:
return "" return ""
app = self.get_rattail_app() app = self.request.rattail_config.get_app()
return app.render_quantity(value) return app.render_quantity(value)
def render_percent(self, obj, field): def render_percent(self, obj, field):
@ -1212,12 +1375,19 @@ class Form(object):
def obtain_value(self, record, field_name): def obtain_value(self, record, field_name):
if record: if record:
if isinstance(record, dict):
return record[field_name]
try:
return getattr(record, field_name)
except AttributeError:
pass
try: try:
return record[field_name] return record[field_name]
except KeyError:
return None
except TypeError: except TypeError:
return getattr(record, field_name, None) pass
# TODO: is this always safe to do? # TODO: is this always safe to do?
elif self.defaults and field_name in self.defaults: elif self.defaults and field_name in self.defaults:
@ -1271,30 +1441,6 @@ class Form(object):
return False return False
class FieldList(list):
"""
Convenience wrapper for a form's field list.
"""
def insert_before(self, field, newfield):
if field in self:
i = self.index(field)
self.insert(i, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
def insert_after(self, field, newfield):
if field in self:
i = self.index(field)
self.insert(i + 1, newfield)
else:
log.warning("field '%s' not found, will append new field: %s",
field, newfield)
self.append(newfield)
@colander.deferred @colander.deferred
def upload_widget(node, kw): def upload_widget(node, kw):
request = kw['request'] request = kw['request']

View file

@ -27,6 +27,7 @@ Form Widgets
import json import json
import datetime import datetime
import decimal import decimal
import re
import colander import colander
from deform import widget as dfwidget from deform import widget as dfwidget
@ -249,6 +250,8 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
""" """
template = 'datetime_falafel' template = 'datetime_falafel'
new_pattern = re.compile(r'^\d\d?:\d\d:\d\d [AP]M$')
def serialize(self, field, cstruct, **kw): def serialize(self, field, cstruct, **kw):
""" """ """ """
readonly = kw.get('readonly', self.readonly) readonly = kw.get('readonly', self.readonly)
@ -260,6 +263,13 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
""" """ """ """
if pstruct == '': if pstruct == '':
return colander.null 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 return pstruct
@ -327,6 +337,23 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
return field.renderer(template, **tmpl_values) return field.renderer(template, **tmpl_values)
class FileUploadWidget(dfwidget.FileUploadWidget):
"""
Widget to handle file upload. Must override to add ``use_oruga``
to field template context.
"""
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
def get_template_values(self, field, cstruct, kw):
values = super().get_template_values(field, cstruct, kw)
if self.request:
values['use_oruga'] = self.request.use_oruga
return values
class MultiFileUploadWidget(dfwidget.FileUploadWidget): class MultiFileUploadWidget(dfwidget.FileUploadWidget):
""" """
Widget to handle multiple (arbitrary number) of file uploads. Widget to handle multiple (arbitrary number) of file uploads.
@ -450,7 +477,8 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.request = request 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 # must figure out URL providing autocomplete service
if 'service_url' not in kwargs: if 'service_url' not in kwargs:
@ -471,7 +499,8 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
""" """ """ """
# fetch customer to provide button label, if we have a value # fetch customer to provide button label, if we have a value
if cstruct: if cstruct:
model = self.request.rattail_config.get_model() app = self.request.rattail_config.get_app()
model = app.model
customer = Session.get(model.Customer, cstruct) customer = Session.get(model.Customer, cstruct)
if customer: if customer:
self.field_display = str(customer) self.field_display = str(customer)
@ -525,7 +554,8 @@ class DepartmentWidget(dfwidget.SelectWidget):
def __init__(self, request, **kwargs): def __init__(self, request, **kwargs):
if 'values' not in 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)\ departments = Session.query(model.Department)\
.order_by(model.Department.number) .order_by(model.Department.number)
values = [(dept.uuid, str(dept)) values = [(dept.uuid, str(dept))
@ -567,7 +597,8 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.request = request 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 # must figure out URL providing autocomplete service
if 'service_url' not in kwargs: if 'service_url' not in kwargs:
@ -588,7 +619,8 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget):
""" """ """ """
# fetch vendor to provide button label, if we have a value # fetch vendor to provide button label, if we have a value
if cstruct: if cstruct:
model = self.request.rattail_config.get_model() app = self.request.rattail_config.get_app()
model = app.model
vendor = Session.get(model.Vendor, cstruct) vendor = Session.get(model.Vendor, cstruct)
if vendor: if vendor:
self.field_display = str(vendor) self.field_display = str(vendor)
@ -616,7 +648,8 @@ class VendorDropdownWidget(dfwidget.SelectWidget):
vendors = vendors() vendors = vendors()
else: # default vendor list else: # default vendor list
model = self.request.rattail_config.get_model() app = self.request.rattail_config.get_app()
model = app.model
vendors = Session.query(model.Vendor)\ vendors = Session.query(model.Vendor)\
.order_by(model.Vendor.name)\ .order_by(model.Vendor.name)\
.all() .all()

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,7 @@ Grid Filters
import re import re
import datetime import datetime
import decimal
import logging import logging
from collections import OrderedDict from collections import OrderedDict
@ -647,12 +648,22 @@ class AlchemyNumericFilter(AlchemyGridFilter):
# first just make sure it's somewhat numeric # first just make sure it's somewhat numeric
try: try:
float(value) self.parse_decimal(value)
except ValueError: except decimal.InvalidOperation:
return True return True
return bool(value and len(str(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): def filter_equal(self, query, value):
if self.value_invalid(value): if self.value_invalid(value):
return query return query

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,9 +24,8 @@
Tailbone Handler Tailbone Handler
""" """
from __future__ import unicode_literals, absolute_import import warnings
import six
from mako.lookup import TemplateLookup from mako.lookup import TemplateLookup
from rattail.app import GenericHandler from rattail.app import GenericHandler
@ -41,7 +40,7 @@ class TailboneHandler(GenericHandler):
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(TailboneHandler, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# TODO: make templates dir configurable? # TODO: make templates dir configurable?
templates = [resource_path('rattail:templates/web')] templates = [resource_path('rattail:templates/web')]
@ -49,11 +48,14 @@ class TailboneHandler(GenericHandler):
def get_menu_handler(self, **kwargs): def get_menu_handler(self, **kwargs):
""" """
Get the configured "menu" handler. DEPRECATED; use
:meth:`wuttaweb.handler.WebHandler.get_menu_handler()`
:returns: The :class:`~tailbone.menus.MenuHandler` instance instead.
for the app.
""" """
warnings.warn("TailboneHandler.get_menu_handler() is deprecated; "
"please use WebHandler.get_menu_handler() instead",
DeprecationWarning, stacklevel=2)
if not hasattr(self, 'menu_handler'): if not hasattr(self, 'menu_handler'):
spec = self.config.get('tailbone.menus', 'handler', spec = self.config.get('tailbone.menus', 'handler',
default='tailbone.menus:MenuHandler') default='tailbone.menus:MenuHandler')
@ -67,7 +69,7 @@ class TailboneHandler(GenericHandler):
Returns an iterator over all registered Tailbone providers. Returns an iterator over all registered Tailbone providers.
""" """
providers = get_all_providers(self.config) providers = get_all_providers(self.config)
return six.itervalues(providers) return providers.values()
def write_model_view(self, data, path, **kwargs): def write_model_view(self, data, path, **kwargs):
""" """

View file

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

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar # Copyright © 2010-2024 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,37 +24,48 @@
App Menus App Menus
""" """
import re
import logging import logging
import warnings import warnings
from rattail.app import GenericHandler
from rattail.util import prettify, simple_error from rattail.util import prettify, simple_error
from webhelpers2.html import tags, HTML from webhelpers2.html import tags, HTML
from wuttaweb.menus import MenuHandler as WuttaMenuHandler
from tailbone.db import Session from tailbone.db import Session
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class MenuHandler(GenericHandler): class TailboneMenuHandler(WuttaMenuHandler):
""" """
Base class and default implementation for menu handler. Base class and default implementation for menu handler.
""" """
def make_raw_menus(self, request, **kwargs): ##############################
""" # internal methods
Generate a full set of "raw" menus for the app. ##############################
The "raw" menus are basically just a set of dicts to represent def _is_allowed(self, request, item):
the final menus. """
TODO: must override this until wuttaweb has proper user auth checks
"""
perm = item.get('perm')
if perm:
return request.has_perm(perm)
return True
def _make_raw_menus(self, request, **kwargs):
"""
We are overriding this to allow for making dynamic menus from
config/settings. Which may or may not be a good idea..
""" """
# first try to make menus from config, but this is highly # first try to make menus from config, but this is highly
# susceptible to failure, so try to warn user of problems # susceptible to failure, so try to warn user of problems
try: try:
menus = self.make_menus_from_config(request) menus = self._make_menus_from_config(request)
if menus: if menus:
return menus return menus
except Exception as error: except Exception as error:
@ -71,9 +82,9 @@ class MenuHandler(GenericHandler):
request.session.flash(msg, 'warning') request.session.flash(msg, 'warning')
# okay, no config, so menus will be built from code # okay, no config, so menus will be built from code
return self.make_menus(request) return self.make_menus(request, **kwargs)
def make_menus_from_config(self, request, **kwargs): def _make_menus_from_config(self, request, **kwargs):
""" """
Try to build a complete menu set from config/settings. Try to build a complete menu set from config/settings.
@ -85,7 +96,7 @@ class MenuHandler(GenericHandler):
if not main_keys: if not main_keys:
return return
model = self.model model = self.app.model
menus = [] menus = []
# menu definition can come either from config file or db # menu definition can come either from config file or db
@ -101,16 +112,15 @@ class MenuHandler(GenericHandler):
query=query, key='name', query=query, key='name',
normalizer=lambda s: s.value) normalizer=lambda s: s.value)
for key in main_keys: for key in main_keys:
menus.append(self.make_single_menu_from_settings(request, key, menus.append(self._make_single_menu_from_settings(request, key, settings))
settings))
else: # read from config file only else: # read from config file only
for key in main_keys: for key in main_keys:
menus.append(self.make_single_menu_from_config(request, key)) menus.append(self._make_single_menu_from_config(request, key))
return menus return menus
def make_single_menu_from_config(self, request, key, **kwargs): def _make_single_menu_from_config(self, request, key, **kwargs):
""" """
Makes a single top-level menu dict from config file. Note Makes a single top-level menu dict from config file. Note
that this will read from config file(s) *only* and avoids that this will read from config file(s) *only* and avoids
@ -178,7 +188,7 @@ class MenuHandler(GenericHandler):
return menu return menu
def make_single_menu_from_settings(self, request, key, settings, **kwargs): def _make_single_menu_from_settings(self, request, key, settings, **kwargs):
""" """
Makes a single top-level menu dict from DB settings. Makes a single top-level menu dict from DB settings.
""" """
@ -237,6 +247,10 @@ class MenuHandler(GenericHandler):
return menu return menu
##############################
# menu defaults
##############################
def make_menus(self, request, **kwargs): def make_menus(self, request, **kwargs):
""" """
Make the full set of menus for the app. Make the full set of menus for the app.
@ -267,8 +281,9 @@ class MenuHandler(GenericHandler):
""" """
Make a set of menus for all registered system integrations. Make a set of menus for all registered system integrations.
""" """
tb = self.app.get_tailbone_handler()
menus = [] menus = []
for provider in self.tb.iter_providers(): for provider in tb.iter_providers():
menu = provider.make_integration_menu(request) menu = provider.make_integration_menu(request)
if menu: if menu:
menus.append(menu) menus.append(menu)
@ -379,6 +394,11 @@ class MenuHandler(GenericHandler):
'route': 'products', 'route': 'products',
'perm': 'products.list', 'perm': 'products.list',
}, },
{
'title': "Product Costs",
'route': 'product_costs',
'perm': 'product_costs.list',
},
{ {
'title': "Departments", 'title': "Departments",
'route': 'departments', 'route': 'departments',
@ -436,6 +456,11 @@ class MenuHandler(GenericHandler):
'route': 'vendors', 'route': 'vendors',
'perm': 'vendors.list', 'perm': 'vendors.list',
}, },
{
'title': "Product Costs",
'route': 'product_costs',
'perm': 'product_costs.list',
},
{'type': 'sep'}, {'type': 'sep'},
{ {
'title': "Ordering", 'title': "Ordering",
@ -688,7 +713,7 @@ class MenuHandler(GenericHandler):
}, },
{'type': 'sep'}, {'type': 'sep'},
{ {
'title': "App Details", 'title': "App Info",
'route': 'appinfo', 'route': 'appinfo',
'perm': 'appinfo.list', 'perm': 'appinfo.list',
}, },
@ -723,182 +748,25 @@ class MenuHandler(GenericHandler):
} }
def make_simple_menus(request): class MenuHandler(TailboneMenuHandler):
def __init__(self, *args, **kwargs):
warnings.warn("tailbone.menus.MenuHandler is deprecated; "
"please use tailbone.menus.TailboneMenuHandler instead",
DeprecationWarning, stacklevel=2)
super().__init__(*args, **kwargs)
class NullMenuHandler(WuttaMenuHandler):
""" """
Build the main menu list for the app. Null menu handler which uses an empty menu set.
.. note:
This class shouldn't even exist, but for the moment, it is
useful to configure non-traditional (e.g. API) web apps to use
this, in order to avoid most of the overhead.
""" """
app = request.rattail_config.get_app()
tailbone_handler = app.get_tailbone_handler()
menu_handler = tailbone_handler.get_menu_handler()
raw_menus = menu_handler.make_raw_menus(request) def make_menus(self, request, **kwargs):
return []
# now we have "simple" (raw) menus definition, but must refine
# that somewhat to produce our final menus
mark_allowed(request, raw_menus)
final_menus = []
for topitem in raw_menus:
if topitem['allowed']:
if topitem.get('type') == 'link':
final_menus.append(make_menu_entry(request, topitem))
else: # assuming 'menu' type
menu_items = []
for item in topitem['items']:
if not item['allowed']:
continue
# nested submenu
if item.get('type') == 'menu':
submenu_items = []
for subitem in item['items']:
if subitem['allowed']:
submenu_items.append(make_menu_entry(request, subitem))
menu_items.append({
'type': 'submenu',
'title': item['title'],
'items': submenu_items,
'is_menu': True,
'is_sep': False,
})
elif item.get('type') == 'sep':
# we only want to add a sep, *if* we already have some
# menu items (i.e. there is something to separate)
# *and* the last menu item is not a sep (avoid doubles)
if menu_items and not menu_items[-1]['is_sep']:
menu_items.append(make_menu_entry(request, item))
else: # standard menu item
menu_items.append(make_menu_entry(request, item))
# remove final separator if present
if menu_items and menu_items[-1]['is_sep']:
menu_items.pop()
# only add if we wound up with something
assert menu_items
if menu_items:
group = {
'type': 'menu',
'key': topitem.get('key'),
'title': topitem['title'],
'items': menu_items,
'is_menu': True,
'is_link': False,
}
# topitem w/ no key likely means it did not come
# from config but rather explicit definition in
# code. so we are free to "invent" a (safe) key
# for it, since that is only for editing config
if not group['key']:
group['key'] = make_menu_key(request.rattail_config,
topitem['title'])
final_menus.append(group)
return final_menus
def make_menu_key(config, value):
"""
Generate a normalized menu key for the given value.
"""
return re.sub(r'\W', '', value.lower())
def make_menu_entry(request, item):
"""
Convert a simple menu entry dict, into a proper menu-related object, for
use in constructing final menu.
"""
# separator
if item.get('type') == 'sep':
return {
'type': 'sep',
'is_menu': False,
'is_sep': True,
}
# standard menu item
entry = {
'type': 'item',
'title': item['title'],
'perm': item.get('perm'),
'target': item.get('target'),
'is_link': True,
'is_menu': False,
'is_sep': False,
}
if item.get('route'):
entry['route'] = item['route']
try:
entry['url'] = request.route_url(entry['route'])
except KeyError: # happens if no such route
log.warning("invalid route name for menu entry: %s", entry)
entry['url'] = entry['route']
entry['key'] = entry['route']
else:
if item.get('url'):
entry['url'] = item['url']
entry['key'] = make_menu_key(request.rattail_config, entry['title'])
return entry
def is_allowed(request, item):
"""
Logic to determine if a given menu item is "allowed" for current user.
"""
perm = item.get('perm')
if perm:
return request.has_perm(perm)
return True
def mark_allowed(request, menus):
"""
Traverse the menu set, and mark each item as "allowed" (or not) based on
current user permissions.
"""
for topitem in menus:
if topitem.get('type', 'menu') == 'menu':
topitem['allowed'] = False
for item in topitem['items']:
if item.get('type') == 'menu':
for subitem in item['items']:
subitem['allowed'] = is_allowed(request, subitem)
item['allowed'] = False
for subitem in item['items']:
if subitem['allowed'] and subitem.get('type') != 'sep':
item['allowed'] = True
break
else:
item['allowed'] = is_allowed(request, item)
for item in topitem['items']:
if item['allowed'] and item.get('type') != 'sep':
topitem['allowed'] = True
break
def make_admin_menu(request, **kwargs):
"""
Generate a typical Admin menu
"""
warnings.warn("make_admin_menu() function is deprecated; please use "
"MenuHandler.make_admin_menu() instead",
DeprecationWarning, stacklevel=2)
app = request.rattail_config.get_app()
tailbone_handler = app.get_tailbone_handler()
menu_handler = tailbone_handler.get_menu_handler()
return menu_handler.make_admin_menu(request, **kwargs)

View file

@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Pyramid scaffold templates
"""
from __future__ import unicode_literals, absolute_import
from rattail.files import resource_path
from rattail.util import prettify
from pyramid.scaffolds import PyramidTemplate
class RattailTemplate(PyramidTemplate):
_template_dir = resource_path('rattail:data/project')
summary = "Starter project based on Rattail / Tailbone"
def pre(self, command, output_dir, vars):
"""
Adds some more variables to the template context.
"""
vars['project_title'] = prettify(vars['project'])
vars['package_title'] = vars['package'].capitalize()
return super(RattailTemplate, self).pre(command, output_dir, vars)

View file

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

View file

@ -3,10 +3,6 @@
* Grid Filters * Grid Filters
******************************/ ******************************/
.filters .filter {
margin-bottom: 0.5rem;
}
.filters .filter-fieldname .field, .filters .filter-fieldname .field,
.filters .filter-fieldname .field label { .filters .filter-fieldname .field label {
width: 100%; width: 100%;

View file

@ -25,6 +25,11 @@
margin: 0; margin: 0;
} }
.grid-tools {
display: flex;
gap: 0.5rem;
}
.grid-wrapper .grid-header td.tools { .grid-wrapper .grid-header td.tools {
margin: 0; margin: 0;
padding: 0; padding: 0;

View file

@ -90,6 +90,11 @@ header span.header-text {
* "object helper" panel * "object helper" panel
******************************/ ******************************/
.object-helpers .panel {
margin: 1rem;
margin-bottom: 1.5rem;
}
.object-helpers .panel-heading { .object-helpers .panel-heading {
white-space: nowrap; white-space: nowrap;
} }

View file

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

View file

@ -1,167 +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
}
},
watch: {
// when changing from e.g. 'equal' to 'between' filter verbs,
// must proclaim new filter value, to reflect (lack of) range
wantsRange(val) {
if (val) {
this.$emit('input', this.startValue + '|' + this.endValue)
} else {
this.$emit('input', this.startValue)
}
},
},
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

@ -24,9 +24,10 @@
Event Subscribers Event Subscribers
""" """
import six
import json
import datetime import datetime
import logging
import warnings
from collections import OrderedDict
import rattail import rattail
@ -35,161 +36,169 @@ import deform
from pyramid import threadlocal from pyramid import threadlocal
from webhelpers2.html import tags from webhelpers2.html import tags
from wuttaweb import subscribers as base
import tailbone import tailbone
from tailbone import helpers from tailbone import helpers
from tailbone.db import Session from tailbone.db import Session
from tailbone.config import csrf_header_name, should_expose_websockets from tailbone.config import csrf_header_name, should_expose_websockets
from tailbone.menus import make_simple_menus
from tailbone.util import get_available_themes, get_global_search_options 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 Event hook called when processing a new request.
the ``rattail_config`` attribute to the request.
A global Rattail ``config`` should already be present within the Pyramid This first invokes the upstream hooks:
application registry's settings, which would normally be accessed via::
request.registry.settings['rattail_config']
This function merely "promotes" that config object so that it is more * :func:`wuttaweb:wuttaweb.subscribers.new_request()`
directly accessible, a la:: * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()`
request.rattail_config It then adds more things to the request object; among them:
.. note:: .. attribute:: request.rattail_config
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.
Also, attach some goodies to the request object: Reference to the app :term:`config object`. Note that this
will be the same as :attr:`wuttaweb:request.wutta_config`.
* The currently logged-in user instance (if any), as ``user``. .. method:: request.register_component(tagname, classname)
* ``is_admin`` flag indicating whether user has the Administrator role. Function to register a Vue component for use with the app.
* ``is_root`` flag indicating whether user is currently elevated to root. This can be called from wherever a component is defined, and
then in the base template all registered components will be
* A shortcut method for permission checking, as ``has_perm()``. properly loaded.
""" """
request = event.request 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): # invoke main upstream logic
user = None # nb. this sets request.wutta_config
uuid = request.authenticated_userid base.new_request(event)
if uuid:
model = request.rattail_config.get_model()
user = Session.get(model.User, uuid)
if user:
Session().set_continuum_user(user)
return user
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 # 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.register_component()
request.is_root = request.is_admin and request.session.get('is_root', False) 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()
# TODO: why would this ever be null? if tagname in request._tailbone_registered_components:
if rattail_config: 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)
app = rattail_config.get_app() request._tailbone_registered_components[tagname] = classname
auth = app.get_auth_handler() request.register_component = register_component
request.tailbone_cached_permissions = auth.get_permissions(
Session(), request.user)
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 before_render(event): def before_render(event):
""" """
Adds goodies to the global template renderer context. 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() 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 = 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['h'] = helpers
renderer_globals['url'] = request.route_url
renderer_globals['rattail'] = rattail # misc.
renderer_globals['tailbone'] = tailbone
renderer_globals['model'] = request.rattail_config.get_model()
renderer_globals['enum'] = request.rattail_config.get_enum()
renderer_globals['six'] = six
renderer_globals['json'] = json
renderer_globals['datetime'] = datetime renderer_globals['datetime'] = datetime
renderer_globals['colander'] = colander renderer_globals['colander'] = colander
renderer_globals['deform'] = deform 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 # theme - we only want do this for classic web app, *not* API
# TODO: so, clearly we need a better way to distinguish the two # TODO: so, clearly we need a better way to distinguish the two
if 'tailbone.theme' in request.registry.settings: if 'tailbone.theme' in request.registry.settings:
renderer_globals['theme'] = request.registry.settings['tailbone.theme'] renderer_globals['theme'] = request.registry.settings['tailbone.theme']
# note, this is just a global flag; user still needs permission to see picker # note, this is just a global flag; user still needs permission to see picker
expose_picker = request.rattail_config.getbool('tailbone', 'themes.expose_picker', expose_picker = config.get_bool('tailbone.themes.expose_picker',
default=False) default=False)
renderer_globals['expose_theme_picker'] = expose_picker renderer_globals['expose_theme_picker'] = expose_picker
if expose_picker: if expose_picker:
# TODO: should remove 'falafel' option altogether # TODO: should remove 'falafel' option altogether
available = get_available_themes(request.rattail_config, available = get_available_themes(config)
include=['falafel'])
options = [tags.Option(theme, value=theme) for theme in available] options = [tags.Option(theme, value=theme) for theme in available]
renderer_globals['theme_picker_options'] = options 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 # TODO: ugh, same deal here
renderer_globals['messaging_enabled'] = request.rattail_config.getbool( renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled',
'tailbone', 'messaging.enabled', default=False) default=False)
# background color may be set per-request, by some apps # background color may be set per-request, by some apps
if hasattr(request, 'background_color') and request.background_color: if hasattr(request, 'background_color') and request.background_color:
renderer_globals['background_color'] = request.background_color renderer_globals['background_color'] = request.background_color
else: # otherwise we use the one from config else: # otherwise we use the one from config
renderer_globals['background_color'] = request.rattail_config.get( renderer_globals['background_color'] = config.get('tailbone.background_color')
'tailbone', 'background_color')
# maybe set custom stylesheet # maybe set custom stylesheet
css = None css = None
if request.user: if request.user:
css = request.rattail_config.get('tailbone.{}'.format(request.user.uuid), css = config.get(f'tailbone.{request.user.uuid}', 'user_css')
'buefy_css') if not css:
css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css')
if css:
warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be"
f"changed to 'tailbone.{request.user.uuid}.user_css'",
DeprecationWarning)
renderer_globals['user_css'] = css renderer_globals['user_css'] = css
# add global search data for quick access # add global search data for quick access
renderer_globals['global_search_data'] = get_global_search_options(request) renderer_globals['global_search_data'] = get_global_search_options(request)
# here we globally declare widths for grid filter pseudo-columns # 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: if widths:
widths = widths.split(';') widths = widths.split(';')
if len(widths) < 2: if len(widths) < 2:
@ -200,7 +209,7 @@ def before_render(event):
renderer_globals['filter_verb_width'] = widths[1] renderer_globals['filter_verb_width'] = widths[1]
# declare global support for websockets, or lack thereof # 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): def add_inbox_count(event):
@ -214,8 +223,9 @@ def add_inbox_count(event):
request = event.get('request') or threadlocal.get_current_request() request = event.get('request') or threadlocal.get_current_request()
if request.user: if request.user:
renderer_globals = event renderer_globals = event
app = request.rattail_config.get_app()
model = app.model
enum = request.rattail_config.get_enum() enum = request.rattail_config.get_enum()
model = request.rattail_config.get_model()
renderer_globals['inbox_count'] = Session.query(model.Message)\ renderer_globals['inbox_count'] = Session.query(model.Message)\
.outerjoin(model.MessageRecipient)\ .outerjoin(model.MessageRecipient)\
.filter(model.MessageRecipient.recipient == Session.merge(request.user))\ .filter(model.MessageRecipient.recipient == Session.merge(request.user))\
@ -229,27 +239,10 @@ def context_found(event):
The following is attached to the request: The following is attached to the request:
* ``get_referrer()`` function
* ``get_session_timeout()`` function * ``get_session_timeout()`` function
""" """
request = event.request request = event.request
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(): def get_session_timeout():
""" """
Returns the timeout in effect for the current session Returns the timeout in effect for the current session

View file

@ -1,241 +1,2 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/configure.mako" /> <%inherit file="wuttaweb:templates/appinfo/configure.mako" />
<%def name="form_content()">
<h3 class="block is-size-3">Basics</h3>
<div class="block" style="padding-left: 2rem;">
<b-field grouped>
<b-field label="App Title">
<b-input name="rattail.app_title"
v-model="simpleSettings['rattail.app_title']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field label="Node Type">
## TODO: should be a dropdown, app handler defines choices
<b-input name="rattail.node_type"
v-model="simpleSettings['rattail.node_type']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field label="Node Title">
<b-input name="rattail.node_title"
v-model="simpleSettings['rattail.node_title']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</b-field>
<b-field>
<b-checkbox name="rattail.production"
v-model="simpleSettings['rattail.production']"
native-value="true"
@input="settingsNeedSaved = true">
Production Mode
</b-checkbox>
</b-field>
<div class="level-left">
<div class="level-item">
<b-field>
<b-checkbox name="rattail.running_from_source"
v-model="simpleSettings['rattail.running_from_source']"
native-value="true"
@input="settingsNeedSaved = true">
Running from Source
</b-checkbox>
</b-field>
</div>
<div class="level-item">
<b-field label="Top-Level Package" horizontal
v-if="simpleSettings['rattail.running_from_source']">
<b-input name="rattail.running_from_source.rootpkg"
v-model="simpleSettings['rattail.running_from_source.rootpkg']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</div>
</div>
</div>
<h3 class="block is-size-3">Display</h3>
<div class="block" style="padding-left: 2rem;">
<b-field grouped>
<b-field label="Background Color">
<b-input name="tailbone.background_color"
v-model="simpleSettings['tailbone.background_color']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</b-field>
</div>
<h3 class="block is-size-3">Grids</h3>
<div class="block" style="padding-left: 2rem;">
<b-field grouped>
<b-field label="Default Page Size">
<b-input name="tailbone.grid.default_pagesize"
v-model="simpleSettings['tailbone.grid.default_pagesize']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</b-field>
</div>
<h3 class="block is-size-3">Web Libraries</h3>
<div class="block" style="padding-left: 2rem;">
<b-table :data="weblibs">
<b-table-column field="title"
label="Name"
v-slot="props">
{{ props.row.title }}
</b-table-column>
<b-table-column field="configured_version"
label="Version"
v-slot="props">
{{ props.row.configured_version || props.row.default_version }}
</b-table-column>
<b-table-column field="configured_url"
label="URL Override"
v-slot="props">
{{ props.row.configured_url }}
</b-table-column>
<b-table-column field="live_url"
label="Effective (Live) URL"
v-slot="props">
<span v-if="props.row.modified"
class="has-text-warning">
save settings and refresh page to see new URL
</span>
<span v-if="!props.row.modified">
{{ props.row.live_url }}
</span>
</b-table-column>
<b-table-column field="actions"
label="Actions"
v-slot="props">
<a href="#"
@click.prevent="editWebLibraryInit(props.row)">
<i class="fas fa-edit"></i>
Edit
</a>
</b-table-column>
</b-table>
% for weblib in weblibs:
${h.hidden('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})}
${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})}
% endfor
<b-modal has-modal-card
:active.sync="editWebLibraryShowDialog">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Web Library: {{ editWebLibraryRecord.title }}</p>
</header>
<section class="modal-card-body">
<b-field grouped>
<b-field label="Default Version">
<b-input v-model="editWebLibraryRecord.default_version"
disabled>
</b-input>
</b-field>
<b-field label="Override Version">
<b-input v-model="editWebLibraryVersion">
</b-input>
</b-field>
</b-field>
<b-field label="Override URL">
<b-input v-model="editWebLibraryURL">
</b-input>
</b-field>
<b-field label="Effective URL (as of last page load)">
<b-input v-model="editWebLibraryRecord.live_url"
disabled>
</b-input>
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="editWebLibrarySave()"
icon-pack="fas"
icon-left="save">
Save
</b-button>
<b-button @click="editWebLibraryShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</b-modal>
</div>
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
ThisPageData.weblibs = ${json.dumps(weblibs)|n}
ThisPageData.editWebLibraryShowDialog = false
ThisPageData.editWebLibraryRecord = {}
ThisPageData.editWebLibraryVersion = null
ThisPageData.editWebLibraryURL = null
ThisPage.methods.editWebLibraryInit = function(row) {
this.editWebLibraryRecord = row
this.editWebLibraryVersion = row.configured_version
this.editWebLibraryURL = row.configured_url
this.editWebLibraryShowDialog = true
}
ThisPage.methods.editWebLibrarySave = function() {
this.editWebLibraryRecord.configured_version = this.editWebLibraryVersion
this.editWebLibraryRecord.configured_url = this.editWebLibraryURL
this.editWebLibraryRecord.modified = true
this.simpleSettings[`tailbone.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion
this.simpleSettings[`tailbone.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL
this.settingsNeedSaved = true
this.editWebLibraryShowDialog = false
}
</script>
</%def>
${parent.body()}

View file

@ -1,8 +1,7 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" /> <%inherit file="wuttaweb:templates/appinfo/index.mako" />
<%def name="render_grid_component()">
<%def name="page_content()">
<div class="buttons"> <div class="buttons">
<once-button type="is-primary" <once-button type="is-primary"
@ -28,98 +27,5 @@
</div> </div>
<b-collapse class="panel" open> ${parent.page_content()}
<template #trigger="props">
<div class="panel-heading"
role="button">
## TODO: for some reason buefy will "reuse" the icon
## element in such a way that its display does not
## refresh. so to work around that, we use different
## structure for the two icons, so buefy is forced to
## re-draw
<b-icon v-if="props.open"
pack="fas"
icon="angle-down">
</b-icon>
<span v-if="!props.open">
<b-icon pack="fas"
icon="angle-right">
</b-icon>
</span>
<span>Configuration Files (style: ${request.rattail_config._style})</span>
</div>
</template>
<div class="panel-block">
<div style="width: 100%;">
<b-table :data="configFiles">
<b-table-column field="priority"
label="Priority"
v-slot="props">
{{ props.row.priority }}
</b-table-column>
<b-table-column field="path"
label="File Path"
v-slot="props">
{{ props.row.path }}
</b-table-column>
</b-table>
</div>
</div>
</b-collapse>
<b-collapse class="panel"
:open="false">
<template #trigger="props">
<div class="panel-heading"
role="button">
## TODO: for some reason buefy will "reuse" the icon
## element in such a way that its display does not
## refresh. so to work around that, we use different
## structure for the two icons, so buefy is forced to
## re-draw
<b-icon v-if="props.open"
pack="fas"
icon="angle-down">
</b-icon>
<span v-if="!props.open">
<b-icon pack="fas"
icon="angle-right">
</b-icon>
</span>
<strong>Installed Packages</strong>
</div>
</template>
<div class="panel-block">
<div style="width: 100%;">
${parent.render_grid_component()}
</div>
</div>
</b-collapse>
</%def> </%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n}
</script>
</%def>
${parent.body()}

View file

@ -15,8 +15,8 @@
<app-settings :groups="groups" :showing-group="showingGroup"></app-settings> <app-settings :groups="groups" :showing-group="showingGroup"></app-settings>
</%def> </%def>
<%def name="render_this_page_template()"> <%def name="render_vue_templates()">
${parent.render_this_page_template()} ${parent.render_vue_templates()}
<script type="text/x-template" id="app-settings-template"> <script type="text/x-template" id="app-settings-template">
<div class="form"> <div class="form">
@ -150,19 +150,18 @@
</script> </script>
</%def> </%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.groups = ${json.dumps(settings_data)|n} ThisPageData.groups = ${json.dumps(settings_data)|n}
ThisPageData.showingGroup = ${json.dumps(current_group or '')|n} ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
</script> </script>
</%def> </%def>
<%def name="make_this_page_component()"> <%def name="make_vue_components()">
${parent.make_this_page_component()} ${parent.make_vue_components()}
<script type="text/javascript"> <script>
Vue.component('app-settings', { Vue.component('app-settings', {
template: '#app-settings-template', template: '#app-settings-template',
@ -193,6 +192,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -1,8 +1,10 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%namespace file="/wutta-components.mako" import="make_wutta_components" />
<%namespace file="/grids/nav.mako" import="grid_index_nav" /> <%namespace file="/grids/nav.mako" import="grid_index_nav" />
<%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" />
<%namespace name="base_meta" file="/base_meta.mako" /> <%namespace name="base_meta" file="/base_meta.mako" />
<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> <%namespace file="/formposter.mako" import="declare_formposter_mixin" />
<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" />
<%namespace name="page_help" file="/page_help.mako" /> <%namespace name="page_help" file="/page_help.mako" />
<%namespace name="multi_file_upload" file="/multi_file_upload.mako" /> <%namespace name="multi_file_upload" file="/multi_file_upload.mako" />
<!DOCTYPE html> <!DOCTYPE html>
@ -33,17 +35,21 @@
</head> </head>
<body> <body>
${declare_formposter_mixin()} <div id="app" style="height: 100%;">
${self.body()}
<div id="whole-page-app">
<whole-page></whole-page> <whole-page></whole-page>
</div> </div>
${self.render_whole_page_template()} ## TODO: this must come before the self.body() call..but why?
${self.make_whole_page_component()} ${declare_formposter_mixin()}
${self.make_whole_page_app()}
## content body from derived/child template
${self.body()}
## Vue app
${self.render_vue_templates()}
${self.modify_vue_vars()}
${self.make_vue_components()}
${self.make_vue_app()}
</body> </body>
</html> </html>
@ -90,7 +96,6 @@
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + '?ver={}'.format(tailbone.__version__))}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + '?ver={}'.format(tailbone.__version__))}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + '?ver={}'.format(tailbone.__version__))}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.grid.js') + '?ver={}'.format(tailbone.__version__))}
<script type="text/javascript"> <script type="text/javascript">
@ -122,16 +127,16 @@
</%def> </%def>
<%def name="vuejs()"> <%def name="vuejs()">
${h.javascript_link(h.get_liburl(request, 'vue'))} ${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))}
${h.javascript_link(h.get_liburl(request, 'vue_resource'))} ${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))}
</%def> </%def>
<%def name="buefy()"> <%def name="buefy()">
${h.javascript_link(h.get_liburl(request, 'buefy'))} ${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))}
</%def> </%def>
<%def name="fontawesome()"> <%def name="fontawesome()">
<script defer src="${h.get_liburl(request, 'fontawesome')}"></script> <script defer src="${h.get_liburl(request, 'fontawesome', prefix='tailbone')}"></script>
</%def> </%def>
<%def name="extra_javascript()"></%def> <%def name="extra_javascript()"></%def>
@ -151,13 +156,18 @@
${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))} ${h.stylesheet_link(request.static_url('tailbone:static/css/codehilite.css') + '?ver={}'.format(tailbone.__version__))}
<style type="text/css"> <style type="text/css">
.filters .filter-fieldname { .filters .filter-fieldname,
.filters .filter-fieldname .button {
% if filter_fieldname_width is not Undefined:
min-width: ${filter_fieldname_width}; min-width: ${filter_fieldname_width};
% endif
justify-content: left; justify-content: left;
} }
% if filter_fieldname_width is not Undefined:
.filters .filter-verb { .filters .filter-verb {
min-width: ${filter_verb_width}; min-width: ${filter_verb_width};
} }
% endif
</style> </style>
</%def> </%def>
@ -166,15 +176,17 @@
${h.stylesheet_link(user_css)} ${h.stylesheet_link(user_css)}
% else: % else:
## upstream Buefy CSS ## upstream Buefy CSS
${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))}
% endif % endif
</%def> </%def>
<%def name="extra_styles()"></%def> <%def name="extra_styles()">
${base_meta.extra_styles()}
</%def>
<%def name="head_tags()"></%def> <%def name="head_tags()"></%def>
<%def name="render_whole_page_template()"> <%def name="render_vue_template_whole_page()">
<script type="text/x-template" id="whole-page-template"> <script type="text/x-template" id="whole-page-template">
<div> <div>
<header> <header>
@ -273,7 +285,7 @@
<span class="header-text"> <span class="header-text">
${index_title} ${index_title}
</span> </span>
% if master.creatable and master.show_create_link and master.has_perm('create'): % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
<once-button type="is-primary" <once-button type="is-primary"
tag="a" href="${url('{}.create'.format(route_prefix))}" tag="a" href="${url('{}.create'.format(route_prefix))}"
icon-left="plus" icon-left="plus"
@ -299,7 +311,7 @@
<span class="header-text"> <span class="header-text">
${h.link_to(instance_title, instance_url)} ${h.link_to(instance_title, instance_url)}
</span> </span>
% elif master.creatable and master.show_create_link and master.has_perm('create'): % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'):
% if not request.matched_route.name.endswith('.create'): % if not request.matched_route.name.endswith('.create'):
<once-button type="is-primary" <once-button type="is-primary"
tag="a" href="${url('{}.create'.format(route_prefix))}" tag="a" href="${url('{}.create'.format(route_prefix))}"
@ -398,6 +410,7 @@
<div class="level-item"> <div class="level-item">
${h.form(url('change_theme'), method="post", ref='themePickerForm')} ${h.form(url('change_theme'), method="post", ref='themePickerForm')}
${h.csrf_token(request)} ${h.csrf_token(request)}
<input type="hidden" name="referrer" :value="referrer" />
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div style="display: flex; align-items: center; gap: 0.5rem;">
<span>Theme:</span> <span>Theme:</span>
<b-select name="theme" <b-select name="theme"
@ -510,7 +523,7 @@
<b-button type="is-primary" <b-button type="is-primary"
@click="showFeedback()" @click="showFeedback()"
icon-pack="fas" icon-pack="fas"
icon-left="fas fa-comment"> icon-left="comment">
Feedback Feedback
</b-button> </b-button>
</div> </div>
@ -619,9 +632,23 @@
% endif % endif
<div class="navbar-dropdown"> <div class="navbar-dropdown">
% if request.is_root: % if request.is_root:
${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} ${h.form(url('stop_root'), ref='stopBeingRootForm')}
${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="$refs.stopBeingRootForm.submit()"
class="navbar-item root-user">
Stop being root
</a>
${h.end_form()}
% elif request.is_admin: % elif request.is_admin:
${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} ${h.form(url('become_root'), ref='startBeingRootForm')}
${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="$refs.startBeingRootForm.submit()"
class="navbar-item root-user">
Become root
</a>
${h.end_form()}
% endif % endif
% if messaging_enabled: % if messaging_enabled:
${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
@ -629,7 +656,11 @@
% if request.is_root or not request.user.prevent_password_change: % if request.is_root or not request.user.prevent_password_change:
${h.link_to("Change Password", url('change_password'), class_='navbar-item')} ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
% endif % endif
${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} % try:
## nb. does not exist yet for wuttaweb
${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')}
% except:
% endtry
${h.link_to("Logout", url('logout'), class_='navbar-item')} ${h.link_to("Logout", url('logout'), class_='navbar-item')}
</div> </div>
</div> </div>
@ -650,19 +681,19 @@
## TODO: is there a better way to check if viewing parent? ## TODO: is there a better way to check if viewing parent?
% if parent_instance is Undefined: % if parent_instance is Undefined:
% if master.editable and instance_editable and master.has_perm('edit'): % if master.editable and instance_editable and master.has_perm('edit'):
<once-button tag="a" href="${action_url('edit', instance)}" <once-button tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit" icon-left="edit"
text="Edit This"> text="Edit This">
</once-button> </once-button>
% endif % endif
% if master.cloneable and master.has_perm('clone'): % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'):
<once-button tag="a" href="${action_url('clone', instance)}" <once-button tag="a" href="${master.get_action_url('clone', instance)}"
icon-left="object-ungroup" icon-left="object-ungroup"
text="Clone This"> text="Clone This">
</once-button> </once-button>
% endif % endif
% if master.deletable and instance_deletable and master.has_perm('delete'): % if master.deletable and instance_deletable and master.has_perm('delete'):
<once-button tag="a" href="${action_url('delete', instance)}" <once-button tag="a" href="${master.get_action_url('delete', instance)}"
type="is-danger" type="is-danger"
icon-left="trash" icon-left="trash"
text="Delete This"> text="Delete This">
@ -671,7 +702,7 @@
% else: % else:
## viewing row ## viewing row
% if instance_deletable and master.has_perm('delete_row'): % if instance_deletable and master.has_perm('delete_row'):
<once-button tag="a" href="${action_url('delete', instance)}" <once-button tag="a" href="${master.get_action_url('delete', instance)}"
type="is-danger" type="is-danger"
icon-left="trash" icon-left="trash"
text="Delete This"> text="Delete This">
@ -680,13 +711,13 @@
% endif % endif
% elif master and master.editing: % elif master and master.editing:
% if master.viewable and master.has_perm('view'): % if master.viewable and master.has_perm('view'):
<once-button tag="a" href="${action_url('view', instance)}" <once-button tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye" icon-left="eye"
text="View This"> text="View This">
</once-button> </once-button>
% endif % endif
% if master.deletable and instance_deletable and master.has_perm('delete'): % if master.deletable and instance_deletable and master.has_perm('delete'):
<once-button tag="a" href="${action_url('delete', instance)}" <once-button tag="a" href="${master.get_action_url('delete', instance)}"
type="is-danger" type="is-danger"
icon-left="trash" icon-left="trash"
text="Delete This"> text="Delete This">
@ -694,13 +725,13 @@
% endif % endif
% elif master and master.deleting: % elif master and master.deleting:
% if master.viewable and master.has_perm('view'): % if master.viewable and master.has_perm('view'):
<once-button tag="a" href="${action_url('view', instance)}" <once-button tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye" icon-left="eye"
text="View This"> text="View This">
</once-button> </once-button>
% endif % endif
% if master.editable and instance_editable and master.has_perm('edit'): % if master.editable and instance_editable and master.has_perm('edit'):
<once-button tag="a" href="${action_url('edit', instance)}" <once-button tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit" icon-left="edit"
text="Edit This"> text="Edit This">
</once-button> </once-button>
@ -741,11 +772,8 @@
% endif % endif
</%def> </%def>
<%def name="declare_whole_page_vars()"> <%def name="render_vue_script_whole_page()">
${page_help.declare_vars()} <script>
${multi_file_upload.declare_vars()}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
<script type="text/javascript">
let WholePage = { let WholePage = {
template: '#whole-page-template', template: '#whole-page-template',
@ -852,7 +880,8 @@
feedbackMessage: "", feedbackMessage: "",
% if expose_theme_picker and request.has_perm('common.change_app_theme'): % if expose_theme_picker and request.has_perm('common.change_app_theme'):
globalTheme: ${json.dumps(theme)|n}, globalTheme: ${json.dumps(theme or None)|n},
referrer: location.href,
% endif % endif
% if can_edit_help: % if can_edit_help:
@ -861,7 +890,7 @@
globalSearchActive: false, globalSearchActive: false,
globalSearchTerm: '', globalSearchTerm: '',
globalSearchData: ${json.dumps(global_search_data)|n}, globalSearchData: ${json.dumps(global_search_data or [])|n},
mountedHooks: [], mountedHooks: [],
} }
@ -880,54 +909,6 @@
</script> </script>
</%def> </%def>
<%def name="modify_whole_page_vars()">
<script type="text/javascript">
% if request.user:
FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n}
% endif
</script>
</%def>
<%def name="finalize_whole_page_vars()">
## NOTE: if you override this, must use <script> tags
</%def>
<%def name="make_whole_page_component()">
${self.declare_whole_page_vars()}
${self.modify_whole_page_vars()}
${self.finalize_whole_page_vars()}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))}
${page_help.make_component()}
${multi_file_upload.make_component()}
<script type="text/javascript">
FeedbackForm.data = function() { return FeedbackFormData }
Vue.component('feedback-form', FeedbackForm)
WholePage.data = function() { return WholePageData }
Vue.component('whole-page', WholePage)
</script>
</%def>
<%def name="make_whole_page_app()">
<script type="text/javascript">
new Vue({
el: '#whole-page-app'
})
</script>
</%def>
<%def name="wtfield(form, name, **kwargs)"> <%def name="wtfield(form, name, **kwargs)">
<div class="field-wrapper${' error' if form[name].errors else ''}"> <div class="field-wrapper${' error' if form[name].errors else ''}">
<label for="${name}">${form[name].label}</label> <label for="${name}">${form[name].label}</label>
@ -949,3 +930,88 @@
</div> </div>
</div> </div>
</%def> </%def>
##############################
## vue components + app
##############################
<%def name="render_vue_templates()">
${page_help.declare_vars()}
${multi_file_upload.declare_vars()}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))}
## DEPRECATED; called for back-compat
${self.render_whole_page_template()}
</%def>
## DEPRECATED; remains for back-compat
<%def name="render_whole_page_template()">
${self.render_vue_template_whole_page()}
${self.declare_whole_page_vars()}
</%def>
## DEPRECATED; remains for back-compat
<%def name="declare_whole_page_vars()">
${self.render_vue_script_whole_page()}
</%def>
<%def name="modify_vue_vars()">
## DEPRECATED; called for back-compat
${self.modify_whole_page_vars()}
</%def>
## DEPRECATED; remains for back-compat
<%def name="modify_whole_page_vars()">
<script>
% if request.user:
FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
FeedbackFormData.userName = ${json.dumps(str(request.user))|n}
% endif
</script>
</%def>
<%def name="make_vue_components()">
${make_wutta_components()}
${make_grid_filter_components()}
${page_help.make_component()}
${multi_file_upload.make_component()}
<script>
FeedbackForm.data = function() { return FeedbackFormData }
Vue.component('feedback-form', FeedbackForm)
</script>
## DEPRECATED; called for back-compat
${self.finalize_whole_page_vars()}
${self.make_whole_page_component()}
</%def>
## DEPRECATED; remains for back-compat
<%def name="make_whole_page_component()">
<script>
WholePage.data = function() { return WholePageData }
Vue.component('whole-page', WholePage)
</script>
</%def>
<%def name="make_vue_app()">
## DEPRECATED; called for back-compat
${self.make_whole_page_app()}
</%def>
## DEPRECATED; remains for back-compat
<%def name="make_whole_page_app()">
<script>
new Vue({
el: '#app'
})
</script>
</%def>
##############################
## DEPRECATED
##############################
<%def name="finalize_whole_page_vars()"></%def>

View file

@ -1,8 +1,7 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/base_meta.mako" />
<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")}</%def> <%def name="app_title()">${app.get_node_title()}</%def>
<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def>
<%def name="favicon()"> <%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'))}" /> <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()"> <%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;")} ${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>
<%def name="footer()">
<p class="has-text-centered">
powered by ${h.link_to("Rattail", url('about'))}
</p>
</%def>

View file

@ -9,7 +9,7 @@
<b-button type="is-primary" <b-button type="is-primary"
:disabled="refreshResultsButtonDisabled" :disabled="refreshResultsButtonDisabled"
icon-pack="fas" icon-pack="fas"
icon-left="fas fa-redo" icon-left="redo"
@click="refreshResults()"> @click="refreshResults()">
{{ refreshResultsButtonText }} {{ refreshResultsButtonText }}
</b-button> </b-button>
@ -43,7 +43,7 @@
<br /> <br />
<div class="form-wrapper"> <div class="form-wrapper">
<div class="form"> <div class="form">
<${execute_form.component} ref="executeResultsForm"></${execute_form.component}> ${execute_form.render_vue_tag(ref='executeResultsForm')}
</div> </div>
</div> </div>
</section> </section>
@ -64,10 +64,17 @@
% endif % endif
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="render_vue_templates()">
${parent.modify_this_page_vars()} ${parent.render_vue_templates()}
% if master.results_executable and master.has_perm('execute_multiple'):
${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
% endif
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
% if master.results_refreshable and master.has_perm('refresh'): % if master.results_refreshable and master.has_perm('refresh'):
<script type="text/javascript"> <script>
TailboneGridData.refreshResultsButtonText = "Refresh Results" TailboneGridData.refreshResultsButtonText = "Refresh Results"
TailboneGridData.refreshResultsButtonDisabled = false TailboneGridData.refreshResultsButtonDisabled = false
@ -81,9 +88,9 @@
</script> </script>
% endif % endif
% if master.results_executable and master.has_perm('execute_multiple'): % if master.results_executable and master.has_perm('execute_multiple'):
<script type="text/javascript"> <script>
${execute_form.component_studly}.methods.submit = function() { ${execute_form.vue_component}.methods.submit = function() {
this.$refs.actualExecuteForm.submit() this.$refs.actualExecuteForm.submit()
} }
@ -118,25 +125,9 @@
% endif % endif
</%def> </%def>
<%def name="make_this_page_component()"> <%def name="make_vue_components()">
${parent.make_this_page_component()} ${parent.make_vue_components()}
% if master.results_executable and master.has_perm('execute_multiple'): % if master.results_executable and master.has_perm('execute_multiple'):
<script type="text/javascript"> ${execute_form.render_vue_finalize()}
${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data }
Vue.component('${execute_form.component}', ${execute_form.component_studly})
</script>
% endif % endif
</%def> </%def>
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
% if master.results_executable and master.has_perm('execute_multiple'):
${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
% endif
</%def>
${parent.body()}

View file

@ -147,7 +147,7 @@
<script type="text/javascript"> <script type="text/javascript">
let ${form.component_studly} = { let ${form.vue_component} = {
template: '#${form.component}-template', template: '#${form.component}-template',
mixins: [SimpleRequestMixin], mixins: [SimpleRequestMixin],
@ -278,7 +278,7 @@
}, },
} }
let ${form.component_studly}Data = { let ${form.vue_component}Data = {
submitting: false, submitting: false,
productUPC: null, productUPC: null,
@ -297,14 +297,9 @@
</script> </script>
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
ThisPageData.toggleCompleteSubmitting = false ThisPageData.toggleCompleteSubmitting = false
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -1,13 +1,9 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/batch/view.mako" /> <%inherit file="/batch/view.mako" />
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n}
${form.component_studly}Data.taxesData = ${json.dumps(taxes_data)|n}
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -39,14 +39,9 @@
</div> </div>
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n} ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n}
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -1,16 +1,16 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/batch/create.mako" /> <%inherit file="/batch/create.mako" />
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n} ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n}
${form.component_studly}Data.vendorName = null ${form.vue_component}Data.vendorName = null
${form.component_studly}Data.vendorNameReplacement = null ${form.vue_component}Data.vendorNameReplacement = null
${form.component_studly}.watch.field_model_parser_key = function(val) { ${form.vue_component}.watch.field_model_parser_key = function(val) {
let parser = this.parsers[val] let parser = this.parsers[val]
if (parser.vendor_uuid) { if (parser.vendor_uuid) {
if (this.field_model_vendor_uuid != parser.vendor_uuid) { if (this.field_model_vendor_uuid != parser.vendor_uuid) {
@ -24,11 +24,11 @@
} }
} }
${form.component_studly}.methods.vendorLabelChanging = function(label) { ${form.vue_component}.methods.vendorLabelChanging = function(label) {
this.vendorNameReplacement = label this.vendorNameReplacement = label
} }
${form.component_studly}.methods.vendorChanged = function(uuid) { ${form.vue_component}.methods.vendorChanged = function(uuid) {
if (uuid) { if (uuid) {
this.vendorName = this.vendorNameReplacement this.vendorName = this.vendorNameReplacement
this.vendorNameReplacement = null this.vendorNameReplacement = null
@ -37,6 +37,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -50,12 +50,12 @@
<b-button tag="a" <b-button tag="a"
href="${master.get_action_url('download_worksheet', batch)}" href="${master.get_action_url('download_worksheet', batch)}"
icon-pack="fas" icon-pack="fas"
icon-left="fas fa-download"> icon-left="download">
Download Worksheet Download Worksheet
</b-button> </b-button>
<b-button type="is-primary" <b-button type="is-primary"
icon-pack="fas" icon-pack="fas"
icon-left="fas fa-upload" icon-left="upload"
@click="$emit('show-upload')"> @click="$emit('show-upload')">
Upload Worksheet Upload Worksheet
</b-button> </b-button>
@ -68,28 +68,28 @@
</%def> </%def>
<%def name="render_status_breakdown()"> <%def name="render_status_breakdown()">
<div class="object-helper"> <nav class="panel">
<h3>Row Status Breakdown</h3> <p class="panel-heading">Row Status</p>
<div class="object-helper-content"> <div class="panel-block">
${status_breakdown_grid} <div style="width: 100%;">
${status_breakdown_grid}
</div>
</div> </div>
</div> </nav>
</%def> </%def>
<%def name="render_execute_helper()"> <%def name="render_execute_helper()">
<div class="object-helper"> <nav class="panel">
<h3>Batch Execution</h3> <p class="panel-heading">Execution</p>
<div class="object-helper-content"> <div class="panel-block">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
% if batch.executed: % if batch.executed:
<p> <p>
Batch was executed
${h.pretty_datetime(request.rattail_config, batch.executed)} ${h.pretty_datetime(request.rattail_config, batch.executed)}
by ${batch.executed_by} by ${batch.executed_by}
</p> </p>
% elif master.handler.executable(batch): % elif master.handler.executable(batch):
% if master.has_perm('execute'): % if master.has_perm('execute'):
<p>Batch has not yet been executed.</p>
<br />
<b-button type="is-primary" <b-button type="is-primary"
% if not execute_enabled: % if not execute_enabled:
disabled disabled
@ -119,8 +119,7 @@
<div class="markdown"> <div class="markdown">
${execution_described|n} ${execution_described|n}
</div> </div>
<${execute_form.component} ref="executeBatchForm"> ${execute_form.render_vue_tag(ref='executeBatchForm')}
</${execute_form.component}>
</section> </section>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
@ -144,14 +143,9 @@
% else: % else:
<p>TODO: batch cannot be executed..?</p> <p>TODO: batch cannot be executed..?</p>
% endif % endif
</div>
</div> </div>
</div> </nav>
</%def>
<%def name="render_form_template()">
## TODO: should use self.render_form_buttons()
## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n}
${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n}
</%def> </%def>
<%def name="render_this_page()"> <%def name="render_this_page()">
@ -173,8 +167,7 @@
Please be certain to use the right one! Please be certain to use the right one!
</p> </p>
<br /> <br />
<${upload_worksheet_form.component} ref="uploadForm"> ${upload_worksheet_form.render_vue_tag(ref='uploadForm')}
</${upload_worksheet_form.component}>
</section> </section>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
@ -184,7 +177,7 @@
<b-button type="is-primary" <b-button type="is-primary"
@click="submitUpload()" @click="submitUpload()"
icon-pack="fas" icon-pack="fas"
icon-left="fas fa-upload" icon-left="upload"
:disabled="uploadButtonDisabled"> :disabled="uploadButtonDisabled">
{{ uploadButtonText }} {{ uploadButtonText }}
</b-button> </b-button>
@ -196,16 +189,6 @@
</%def> </%def>
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
% if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n}
% endif
% if master.handler.executable(batch) and master.has_perm('execute'):
${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
% endif
</%def>
<%def name="render_form()"> <%def name="render_form()">
<div class="form"> <div class="form">
<${form.component} @show-upload="showUploadDialog = true"> <${form.component} @show-upload="showUploadDialog = true">
@ -266,9 +249,27 @@
% endif % endif
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="render_vue_templates()">
${parent.modify_this_page_vars()} ${parent.render_vue_templates()}
<script type="text/javascript"> % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
${upload_worksheet_form.render_vue_template(buttons=False, form_kwargs={'ref': 'actualUploadForm'})}
% endif
% if master.handler.executable(batch) and master.has_perm('execute'):
${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
% endif
</%def>
## DEPRECATED; remains for back-compat
## nb. this is called by parent template, /form.mako
<%def name="render_form_template()">
## TODO: should use self.render_form_buttons()
## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n}
${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n}
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n} ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n}
@ -284,7 +285,7 @@
} }
% if not batch.executed and master.has_perm('edit'): % if not batch.executed and master.has_perm('edit'):
${form.component_studly}Data.togglingBatchComplete = false ${form.vue_component}Data.togglingBatchComplete = false
% endif % endif
% if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
@ -305,7 +306,7 @@
form.submit() form.submit()
} }
${upload_worksheet_form.component_studly}.methods.submit = function() { ${upload_worksheet_form.vue_component}.methods.submit = function() {
this.$refs.actualUploadForm.submit() this.$refs.actualUploadForm.submit()
} }
@ -320,7 +321,7 @@
this.$refs.executeBatchForm.submit() this.$refs.executeBatchForm.submit()
} }
${execute_form.component_studly}.methods.submit = function() { ${execute_form.vue_component}.methods.submit = function() {
this.$refs.actualExecuteForm.submit() this.$refs.actualExecuteForm.submit()
} }
@ -328,9 +329,9 @@
% if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'):
${rows_grid.component_studly}Data.deleteResultsShowDialog = false ${rows_grid.vue_component}Data.deleteResultsShowDialog = false
${rows_grid.component_studly}.methods.deleteResultsInit = function() { ${rows_grid.vue_component}.methods.deleteResultsInit = function() {
this.deleteResultsShowDialog = true this.deleteResultsShowDialog = true
} }
@ -339,28 +340,12 @@
</script> </script>
</%def> </%def>
<%def name="make_this_page_component()"> <%def name="make_vue_components()">
${parent.make_this_page_component()} ${parent.make_vue_components()}
% if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
<script type="text/javascript"> ${upload_worksheet_form.render_vue_finalize()}
## UploadForm
${upload_worksheet_form.component_studly}.data = function() { return ${upload_worksheet_form.component_studly}Data }
Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.component_studly})
</script>
% endif % endif
% if execute_enabled and master.has_perm('execute'): % if execute_enabled and master.has_perm('execute'):
<script type="text/javascript"> ${execute_form.render_vue_finalize()}
## ExecuteForm
${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data }
Vue.component('${execute_form.component}', ${execute_form.component_studly})
</script>
% endif % endif
</%def> </%def>
${parent.body()}

View file

@ -208,9 +208,9 @@
% endif % endif
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n} ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n}
@ -443,6 +443,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -92,7 +92,7 @@
<b-select name="${tmpl['setting_file']}" <b-select name="${tmpl['setting_file']}"
v-model="inputFileTemplateSettings['${tmpl['setting_file']}']" v-model="inputFileTemplateSettings['${tmpl['setting_file']}']"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true">
<option :value="null">-new-</option> <option value="">-new-</option>
<option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']" <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']"
:key="option" :key="option"
:value="option"> :value="option">
@ -104,22 +104,40 @@
<b-field label="Upload" <b-field label="Upload"
v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']"> v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']">
<b-field class="file is-primary" % if request.use_oruga:
:class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}"> <o-field class="file">
<b-upload name="${tmpl['setting_file']}.upload" <o-upload name="${tmpl['setting_file']}.upload"
v-model="inputFileTemplateUploads['${tmpl['key']}']" v-model="inputFileTemplateUploads['${tmpl['key']}']"
class="file-label" v-slot="{ onclick }"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true">
<span class="file-cta"> <o-button variant="primary"
<b-icon class="file-icon" pack="fas" icon="upload"></b-icon> @click="onclick">
<span class="file-label">Click to upload</span> <o-icon icon="upload" />
</span> <span>Click to upload</span>
</b-upload> </o-button>
<span v-if="inputFileTemplateUploads['${tmpl['key']}']" <span class="file-name" v-if="inputFileTemplateUploads['${tmpl['key']}']">
class="file-name"> {{ inputFileTemplateUploads['${tmpl['key']}'].name }}
{{ inputFileTemplateUploads['${tmpl['key']}'].name }} </span>
</span> </o-upload>
</b-field> </o-field>
% else:
<b-field class="file is-primary"
:class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}">
<b-upload name="${tmpl['setting_file']}.upload"
v-model="inputFileTemplateUploads['${tmpl['key']}']"
class="file-label"
@input="settingsNeedSaved = true">
<span class="file-cta">
<b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
<span class="file-label">Click to upload</span>
</span>
</b-upload>
<span v-if="inputFileTemplateUploads['${tmpl['key']}']"
class="file-name">
{{ inputFileTemplateUploads['${tmpl['key']}'].name }}
</span>
</b-field>
% endif
</b-field> </b-field>
@ -143,6 +161,85 @@
</div> </div>
</%def> </%def>
<%def name="output_file_template_field(key)">
<% tmpl = output_file_templates[key] %>
<b-field grouped>
<b-field label="${tmpl['label']}">
<b-select name="${tmpl['setting_mode']}"
v-model="outputFileTemplateSettings['${tmpl['setting_mode']}']"
@input="settingsNeedSaved = true">
<option value="default">use default</option>
<option value="hosted">use uploaded file</option>
</b-select>
</b-field>
<b-field label="File"
v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted'"
:message="outputFileTemplateSettings['${tmpl['setting_file']}'] ? 'This file lives on disk at: ${output_file_option_dirs[tmpl['key']]}' : null">
<b-select name="${tmpl['setting_file']}"
v-model="outputFileTemplateSettings['${tmpl['setting_file']}']"
@input="settingsNeedSaved = true">
<option value="">-new-</option>
<option v-for="option in outputFileTemplateFileOptions['${tmpl['key']}']"
:key="option"
:value="option">
{{ option }}
</option>
</b-select>
</b-field>
<b-field label="Upload"
v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !outputFileTemplateSettings['${tmpl['setting_file']}']">
% if request.use_oruga:
<o-field class="file">
<o-upload name="${tmpl['setting_file']}.upload"
v-model="outputFileTemplateUploads['${tmpl['key']}']"
v-slot="{ onclick }"
@input="settingsNeedSaved = true">
<o-button variant="primary"
@click="onclick">
<o-icon icon="upload" />
<span>Click to upload</span>
</o-button>
<span class="file-name" v-if="outputFileTemplateUploads['${tmpl['key']}']">
{{ outputFileTemplateUploads['${tmpl['key']}'].name }}
</span>
</o-upload>
</o-field>
% else:
<b-field class="file is-primary"
:class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}">
<b-upload name="${tmpl['setting_file']}.upload"
v-model="outputFileTemplateUploads['${tmpl['key']}']"
class="file-label"
@input="settingsNeedSaved = true">
<span class="file-cta">
<b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
<span class="file-label">Click to upload</span>
</span>
</b-upload>
<span v-if="outputFileTemplateUploads['${tmpl['key']}']"
class="file-name">
{{ outputFileTemplateUploads['${tmpl['key']}'].name }}
</span>
</b-field>
% endif
</b-field>
</b-field>
</%def>
<%def name="output_file_templates_section()">
<h3 class="block is-size-3">Output File Templates</h3>
<div class="block" style="padding-left: 2rem;">
% for key in output_file_templates:
${self.output_file_template_field(key)}
% endfor
</div>
</%def>
<%def name="form_content()"></%def> <%def name="form_content()"></%def>
<%def name="page_content()"> <%def name="page_content()">
@ -183,15 +280,14 @@
<b-button @click="purgeSettingsShowDialog = false"> <b-button @click="purgeSettingsShowDialog = false">
Cancel Cancel
</b-button> </b-button>
${h.form(request.current_route_url())} ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})}
${h.csrf_token(request)} ${h.csrf_token(request)}
${h.hidden('remove_settings', 'true')} ${h.hidden('remove_settings', 'true')}
<b-button type="is-danger" <b-button type="is-danger"
native-type="submit" native-type="submit"
:disabled="purgingSettings" :disabled="purgingSettings"
icon-pack="fas" icon-pack="fas"
icon-left="trash" icon-left="trash">
@click="purgingSettings = true">
{{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }} {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }}
</b-button> </b-button>
${h.end_form()} ${h.end_form()}
@ -205,62 +301,42 @@
${h.end_form()} ${h.end_form()}
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
% if simple_settings is not Undefined: % if simple_settings is not Undefined:
ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n}
% endif % endif
% if input_file_template_settings is not Undefined:
ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n}
ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n}
ThisPageData.inputFileTemplateUploads = {
% for key in input_file_templates:
'${key}': null,
% endfor
}
% endif
ThisPageData.purgeSettingsShowDialog = false ThisPageData.purgeSettingsShowDialog = false
ThisPageData.purgingSettings = false ThisPageData.purgingSettings = false
ThisPageData.settingsNeedSaved = false ThisPageData.settingsNeedSaved = false
ThisPageData.undoChanges = false ThisPageData.undoChanges = false
ThisPageData.savingSettings = false ThisPageData.savingSettings = false
ThisPageData.validators = []
ThisPage.methods.purgeSettingsInit = function() { ThisPage.methods.purgeSettingsInit = function() {
this.purgeSettingsShowDialog = true this.purgeSettingsShowDialog = true
} }
% if input_file_template_settings is not Undefined: ThisPage.methods.validateSettings = function() {}
ThisPage.methods.validateInputFileTemplateSettings = function() {
% for tmpl in six.itervalues(input_file_templates):
if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) {
if (!this.inputFileTemplateUploads['${tmpl['key']}']) {
return "You must provide a file to upload for the ${tmpl['label']} template."
}
}
}
% endfor
}
% endif
ThisPage.methods.validateSettings = function() {
let msg
% if input_file_template_settings is not Undefined:
msg = this.validateInputFileTemplateSettings()
if (msg) {
return msg
}
% endif
}
ThisPage.methods.saveSettings = function() { ThisPage.methods.saveSettings = function() {
let msg = this.validateSettings() let msg
// nb. this is the future
for (let validator of this.validators) {
msg = validator.call(this)
if (msg) {
alert(msg)
return
}
}
// nb. legacy method
msg = this.validateSettings()
if (msg) { if (msg) {
alert(msg) alert(msg)
return return
@ -291,8 +367,65 @@
window.addEventListener('beforeunload', this.beforeWindowUnload) window.addEventListener('beforeunload', this.beforeWindowUnload)
} }
##############################
## input file templates
##############################
% if input_file_template_settings is not Undefined:
ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n}
ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n}
ThisPageData.inputFileTemplateUploads = {
% for key in input_file_templates:
'${key}': null,
% endfor
}
ThisPage.methods.validateInputFileTemplateSettings = function() {
% for tmpl in input_file_templates.values():
if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) {
if (!this.inputFileTemplateUploads['${tmpl['key']}']) {
return "You must provide a file to upload for the ${tmpl['label']} template."
}
}
}
% endfor
}
ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings)
% endif
##############################
## output file templates
##############################
% if output_file_template_settings is not Undefined:
ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n}
ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n}
ThisPageData.outputFileTemplateUploads = {
% for key in output_file_templates:
'${key}': null,
% endfor
}
ThisPage.methods.validateOutputFileTemplateSettings = function() {
% for tmpl in output_file_templates.values():
if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) {
if (!this.outputFileTemplateUploads['${tmpl['key']}']) {
return "You must provide a file to upload for the ${tmpl['label']} template."
}
}
}
% endfor
}
ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings)
% endif
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -88,9 +88,9 @@
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
ThisPage.methods.getLabelForKey = function(key) { ThisPage.methods.getLabelForKey = function(key) {
switch (key) { switch (key) {
@ -111,6 +111,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -106,9 +106,9 @@
% endif % endif
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
ThisPageData.resolvePersonShowDialog = false ThisPageData.resolvePersonShowDialog = false
ThisPageData.resolvePersonUUID = null ThisPageData.resolvePersonUUID = null
@ -139,5 +139,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -16,15 +16,15 @@
</div> </div>
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
% if expose_shoppers: % if expose_shoppers:
${form.component_studly}Data.shoppers = ${json.dumps(shoppers_data)|n} ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n}
% endif % endif
% if expose_people: % if expose_people:
${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n} ${form.vue_component}Data.peopleData = ${json.dumps(people_data)|n}
% endif % endif
ThisPage.methods.detachPerson = function(url) { ThisPage.methods.detachPerson = function(url) {
@ -36,5 +36,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -24,29 +24,38 @@
</b-checkbox> </b-checkbox>
</b-field> </b-field>
<b-field message="Only applies if user is allowed to choose contact info."> <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']"
<b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" style="padding-left: 2rem;">
v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
native-value="true"
@input="settingsNeedSaved = true">
Allow user to enter new contact info
</b-checkbox>
</b-field>
<p class="block"> <b-field message="Only applies if user is allowed to choose contact info.">
If you allow users to enter new contact info, the default action <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create"
when the order is submitted, is to send email with details of v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
the new contact info.&nbsp; Settings for these are at: native-value="true"
</p> @input="settingsNeedSaved = true">
Allow user to enter new contact info
</b-checkbox>
</b-field>
<ul class="list"> <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']"
<li class="list-item"> style="padding-left: 2rem;">
${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))}
</li> <p class="block">
<li class="list-item"> If you allow users to enter new contact info, the default action
${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} when the order is submitted, is to send email with details of
</li> the new contact info.&nbsp; Settings for these are at:
</ul> </p>
<ul class="list">
<li class="list-item">
${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))}
</li>
<li class="list-item">
${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))}
</li>
</ul>
</div>
</div>
</div> </div>
<h3 class="block is-size-3">Product Handling</h3> <h3 class="block is-size-3">Product Handling</h3>

File diff suppressed because it is too large Load diff

View file

@ -291,11 +291,11 @@
% endif % endif
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
${form.component_studly}Data.eventsData = ${json.dumps(events_data)|n} ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n}
% if master.has_perm('confirm_price'): % if master.has_perm('confirm_price'):
@ -347,7 +347,7 @@
} }
ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n} ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n}
ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n} ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in enum.CUSTORDER_ITEM_STATUS.items()])|n}
ThisPageData.oldStatusCode = ${instance.status_code} ThisPageData.oldStatusCode = ${instance.status_code}
@ -392,9 +392,9 @@
this.$refs.changeStatusForm.submit() this.$refs.changeStatusForm.submit()
} }
${form.component_studly}Data.changeFlaggedSubmitting = false ${form.vue_component}Data.changeFlaggedSubmitting = false
${form.component_studly}.methods.changeFlaggedSubmit = function() { ${form.vue_component}.methods.changeFlaggedSubmit = function() {
this.changeFlaggedSubmitting = true this.changeFlaggedSubmitting = true
} }
@ -448,5 +448,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -1,13 +1,6 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" /> <%inherit file="/master/index.mako" />
<%def name="context_menu_items()">
${parent.context_menu_items()}
% if request.has_perm('datasync.status'):
<li>${h.link_to("View DataSync Status", url('datasync.status'))}</li>
% endif
</%def>
<%def name="grid_tools()"> <%def name="grid_tools()">
${parent.grid_tools()} ${parent.grid_tools()}
@ -33,9 +26,9 @@
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
% if request.has_perm('datasync.restart'): % if request.has_perm('datasync.restart'):
TailboneGridData.restartDatasyncFormSubmitting = false TailboneGridData.restartDatasyncFormSubmitting = false
@ -57,6 +50,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -1,6 +1,15 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/configure.mako" /> <%inherit file="/configure.mako" />
<%def name="extra_styles()">
${parent.extra_styles()}
<style>
.invisible-watcher {
display: none;
}
</style>
</%def>
<%def name="buttons_row()"> <%def name="buttons_row()">
<div class="level"> <div class="level">
<div class="level-left"> <div class="level-left">
@ -48,7 +57,12 @@
${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})} ${h.hidden('profiles', **{':value': 'JSON.stringify(profilesData)'})}
<b-notification type="is-warning" <b-notification type="is-warning"
:active.sync="showConfigFilesNote"> % if request.use_oruga:
v-model:active="showConfigFilesNote"
% else:
:active.sync="showConfigFilesNote"
% endif
>
## TODO: should link to some ratman page here, yes? ## TODO: should link to some ratman page here, yes?
<p class="block"> <p class="block">
This tool works by modifying settings in the DB.&nbsp; It This tool works by modifying settings in the DB.&nbsp; It
@ -69,8 +83,8 @@
</b-notification> </b-notification>
<b-field> <b-field>
<b-checkbox name="use_profile_settings" <b-checkbox name="rattail.datasync.use_profile_settings"
v-model="useProfileSettings" v-model="simpleSettings['rattail.datasync.use_profile_settings']"
native-value="true" native-value="true"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true">
Use these Settings to configure watchers and consumers Use these Settings to configure watchers and consumers
@ -85,7 +99,7 @@
</div> </div>
<div class="level-right"> <div class="level-right">
<div class="level-item" <div class="level-item"
v-show="useProfileSettings"> v-show="simpleSettings['rattail.datasync.use_profile_settings']">
<b-button type="is-primary" <b-button type="is-primary"
@click="newProfile()" @click="newProfile()"
icon-pack="fas" icon-pack="fas"
@ -101,75 +115,89 @@
</div> </div>
</div> </div>
<b-table :data="filteredProfilesData" <${b}-table :data="profilesData"
:row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> :row-class="getWatcherRowClass">
<b-table-column field="key" <${b}-table-column field="key"
label="Watcher Key" label="Watcher Key"
v-slot="props"> v-slot="props">
{{ props.row.key }} {{ props.row.key }}
</b-table-column> </${b}-table-column>
<b-table-column field="watcher_spec" <${b}-table-column field="watcher_spec"
label="Watcher Spec" label="Watcher Spec"
v-slot="props"> v-slot="props">
{{ props.row.watcher_spec }} {{ props.row.watcher_spec }}
</b-table-column> </${b}-table-column>
<b-table-column field="watcher_dbkey" <${b}-table-column field="watcher_dbkey"
label="DB Key" label="DB Key"
v-slot="props"> v-slot="props">
{{ props.row.watcher_dbkey }} {{ props.row.watcher_dbkey }}
</b-table-column> </${b}-table-column>
<b-table-column field="watcher_delay" <${b}-table-column field="watcher_delay"
label="Loop Delay" label="Loop Delay"
v-slot="props"> v-slot="props">
{{ props.row.watcher_delay }} sec {{ props.row.watcher_delay }} sec
</b-table-column> </${b}-table-column>
<b-table-column field="watcher_retry_attempts" <${b}-table-column field="watcher_retry_attempts"
label="Attempts / Delay" label="Attempts / Delay"
v-slot="props"> v-slot="props">
{{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec {{ props.row.watcher_retry_attempts }} / {{ props.row.watcher_retry_delay }} sec
</b-table-column> </${b}-table-column>
<b-table-column field="watcher_default_runas" <${b}-table-column field="watcher_default_runas"
label="Default Runas" label="Default Runas"
v-slot="props"> v-slot="props">
{{ props.row.watcher_default_runas }} {{ props.row.watcher_default_runas }}
</b-table-column> </${b}-table-column>
<b-table-column label="Consumers" <${b}-table-column label="Consumers"
v-slot="props"> v-slot="props">
{{ consumerShortList(props.row) }} {{ consumerShortList(props.row) }}
</b-table-column> </${b}-table-column>
## <b-table-column field="notes" label="Notes"> ## <${b}-table-column field="notes" label="Notes">
## TODO ## TODO
## ## {{ props.row.notes }} ## ## {{ props.row.notes }}
## </b-table-column> ## </${b}-table-column>
<b-table-column field="enabled" <${b}-table-column field="enabled"
label="Enabled" label="Enabled"
v-slot="props"> v-slot="props">
{{ props.row.enabled ? "Yes" : "No" }} {{ props.row.enabled ? "Yes" : "No" }}
</b-table-column> </${b}-table-column>
<b-table-column label="Actions" <${b}-table-column label="Actions"
v-slot="props" v-slot="props"
v-if="useProfileSettings"> v-if="simpleSettings['rattail.datasync.use_profile_settings']">
<a href="#" <a href="#"
class="grid-action" class="grid-action"
@click.prevent="editProfile(props.row)"> @click.prevent="editProfile(props.row)">
<i class="fas fa-edit"></i> % if request.use_oruga:
Edit <span class="icon-text">
<o-icon icon="edit" />
<span>Edit</span>
</span>
% else:
<i class="fas fa-edit"></i>
Edit
% endif
</a> </a>
&nbsp; &nbsp;
<a href="#" <a href="#"
class="grid-action has-text-danger" class="grid-action has-text-danger"
@click.prevent="deleteProfile(props.row)"> @click.prevent="deleteProfile(props.row)">
<i class="fas fa-trash"></i> % if request.use_oruga:
Delete <span class="icon-text">
<o-icon icon="trash" />
<span>Delete</span>
</span>
% else:
<i class="fas fa-trash"></i>
Delete
% endif
</a> </a>
</b-table-column> </${b}-table-column>
<template slot="empty"> <template #empty>
<section class="section"> <section class="section">
<div class="content has-text-grey has-text-centered"> <div class="content has-text-grey has-text-centered">
<p> <p>
<b-icon <b-icon
pack="fas" pack="fas"
icon="fas fa-sad-tear" icon="sad-tear"
size="is-large"> size="is-large">
</b-icon> </b-icon>
</p> </p>
@ -177,7 +205,7 @@
</div> </div>
</section> </section>
</template> </template>
</b-table> </${b}-table>
<b-modal :active.sync="editProfileShowDialog"> <b-modal :active.sync="editProfileShowDialog">
<div class="card"> <div class="card">
@ -199,12 +227,12 @@
</b-field> </b-field>
<b-field grouped> <b-field grouped expanded>
<b-field label="Watcher Spec" <b-field label="Watcher Spec"
:type="editingProfileWatcherSpec ? null : 'is-danger'" :type="editingProfileWatcherSpec ? null : 'is-danger'"
expanded> expanded>
<b-input v-model="editingProfileWatcherSpec"> <b-input v-model="editingProfileWatcherSpec" expanded>
</b-input> </b-input>
</b-field> </b-field>
@ -293,40 +321,54 @@
</div> </div>
<b-table :data="editingProfilePendingWatcherKwargs" <${b}-table :data="editingProfilePendingWatcherKwargs"
style="margin-left: 1rem;"> style="margin-left: 1rem;">
<b-table-column field="key" <${b}-table-column field="key"
label="Key" label="Key"
v-slot="props"> v-slot="props">
{{ props.row.key }} {{ props.row.key }}
</b-table-column> </${b}-table-column>
<b-table-column field="value" <${b}-table-column field="value"
label="Value" label="Value"
v-slot="props"> v-slot="props">
{{ props.row.value }} {{ props.row.value }}
</b-table-column> </${b}-table-column>
<b-table-column label="Actions" <${b}-table-column label="Actions"
v-slot="props"> v-slot="props">
<a href="#" <a href="#"
@click.prevent="editProfileWatcherKwarg(props.row)"> @click.prevent="editProfileWatcherKwarg(props.row)">
<i class="fas fa-edit"></i> % if request.use_oruga:
Edit <span class="icon-text">
<o-icon icon="edit" />
<span>Edit</span>
</span>
% else:
<i class="fas fa-edit"></i>
Edit
% endif
</a> </a>
&nbsp; &nbsp;
<a href="#" <a href="#"
class="has-text-danger" class="has-text-danger"
@click.prevent="deleteProfileWatcherKwarg(props.row)"> @click.prevent="deleteProfileWatcherKwarg(props.row)">
<i class="fas fa-trash"></i> % if request.use_oruga:
Delete <span class="icon-text">
<o-icon icon="trash" />
<span>Delete</span>
</span>
% else:
<i class="fas fa-trash"></i>
Delete
% endif
</a> </a>
</b-table-column> </${b}-table-column>
<template slot="empty"> <template #empty>
<section class="section"> <section class="section">
<div class="content has-text-grey has-text-centered"> <div class="content has-text-grey has-text-centered">
<p> <p>
<b-icon <b-icon
pack="fas" pack="fas"
icon="fas fa-sad-tear" icon="sad-tear"
size="is-large"> size="is-large">
</b-icon> </b-icon>
</p> </p>
@ -334,7 +376,7 @@
</div> </div>
</section> </section>
</template> </template>
</b-table> </${b}-table>
</div> </div>
@ -350,41 +392,55 @@
</b-checkbox> </b-checkbox>
</b-field> </b-field>
<b-table :data="editingProfilePendingConsumers" <${b}-table :data="editingProfilePendingConsumers"
v-if="!editingProfileWatcherConsumesSelf" v-if="!editingProfileWatcherConsumesSelf"
:row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> :row-class="(row, i) => row.enabled ? null : 'has-background-warning'">
<b-table-column field="key" <${b}-table-column field="key"
label="Consumer" label="Consumer"
v-slot="props"> v-slot="props">
{{ props.row.key }} {{ props.row.key }}
</b-table-column> </${b}-table-column>
<b-table-column style="white-space: nowrap;" <${b}-table-column style="white-space: nowrap;"
v-slot="props"> v-slot="props">
{{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }} {{ props.row.consumer_delay }} / {{ props.row.consumer_retry_attempts }} / {{ props.row.consumer_retry_delay }}
</b-table-column> </${b}-table-column>
<b-table-column label="Actions" <${b}-table-column label="Actions"
v-slot="props"> v-slot="props">
<a href="#" <a href="#"
class="grid-action" class="grid-action"
@click.prevent="editProfileConsumer(props.row)"> @click.prevent="editProfileConsumer(props.row)">
<i class="fas fa-edit"></i> % if request.use_oruga:
Edit <span class="icon-text">
<o-icon icon="edit" />
<span>Edit</span>
</span>
% else:
<i class="fas fa-edit"></i>
Edit
% endif
</a> </a>
&nbsp; &nbsp;
<a href="#" <a href="#"
class="grid-action has-text-danger" class="grid-action has-text-danger"
@click.prevent="deleteProfileConsumer(props.row)"> @click.prevent="deleteProfileConsumer(props.row)">
<i class="fas fa-trash"></i> % if request.use_oruga:
Delete <span class="icon-text">
<o-icon icon="trash" />
<span>Delete</span>
</span>
% else:
<i class="fas fa-trash"></i>
Delete
% endif
</a> </a>
</b-table-column> </${b}-table-column>
<template slot="empty"> <template #empty>
<section class="section"> <section class="section">
<div class="content has-text-grey has-text-centered"> <div class="content has-text-grey has-text-centered">
<p> <p>
<b-icon <b-icon
pack="fas" pack="fas"
icon="fas fa-sad-tear" icon="sad-tear"
size="is-large"> size="is-large">
</b-icon> </b-icon>
</p> </p>
@ -392,7 +448,7 @@
</div> </div>
</section> </section>
</template> </template>
</b-table> </${b}-table>
</div> </div>
@ -524,31 +580,41 @@
<b-field label="Supervisor Process Name" <b-field label="Supervisor Process Name"
message="This should be the complete name, including group - e.g. poser:poser_datasync" message="This should be the complete name, including group - e.g. poser:poser_datasync"
expanded> expanded>
<b-input name="supervisor_process_name" <b-input name="rattail.datasync.supervisor_process_name"
v-model="supervisorProcessName" v-model="simpleSettings['rattail.datasync.supervisor_process_name']"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true"
expanded>
</b-input> </b-input>
</b-field> </b-field>
<b-field label="Consumer Batch Size"
message="Max number of changes to be consumed at once."
expanded>
<numeric-input name="rattail.datasync.batch_size_limit"
v-model="simpleSettings['rattail.datasync.batch_size_limit']"
@input="settingsNeedSaved = true" />
</b-field>
<h3 class="is-size-3">Legacy</h3>
<b-field label="Restart Command" <b-field label="Restart Command"
message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync"
expanded> expanded>
<b-input name="restart_command" <b-input name="tailbone.datasync.restart"
v-model="restartCommand" v-model="simpleSettings['tailbone.datasync.restart']"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true"
expanded>
</b-input> </b-input>
</b-field> </b-field>
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
ThisPageData.showConfigFilesNote = false ThisPageData.showConfigFilesNote = false
ThisPageData.profilesData = ${json.dumps(profiles_data)|n} ThisPageData.profilesData = ${json.dumps(profiles_data)|n}
ThisPageData.showDisabledProfiles = false ThisPageData.showDisabledProfiles = false
ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n}
ThisPageData.editProfileShowDialog = false ThisPageData.editProfileShowDialog = false
ThisPageData.editingProfile = null ThisPageData.editingProfile = null
@ -573,22 +639,6 @@
ThisPageData.editingConsumerRunas = null ThisPageData.editingConsumerRunas = null
ThisPageData.editingConsumerEnabled = true ThisPageData.editingConsumerEnabled = true
ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n}
ThisPageData.restartCommand = ${json.dumps(restart_command)|n}
ThisPage.computed.filteredProfilesData = function() {
if (this.showDisabledProfiles) {
return this.profilesData
}
let data = []
for (let row of this.profilesData) {
if (row.enabled) {
data.push(row)
}
}
return data
}
ThisPage.computed.updateConsumerDisabled = function() { ThisPage.computed.updateConsumerDisabled = function() {
if (!this.editingConsumerKey) { if (!this.editingConsumerKey) {
return true return true
@ -616,6 +666,15 @@
this.showDisabledProfiles = !this.showDisabledProfiles this.showDisabledProfiles = !this.showDisabledProfiles
} }
ThisPage.methods.getWatcherRowClass = function(row, i) {
if (!row.enabled) {
if (!this.showDisabledProfiles) {
return 'invisible-watcher'
}
return 'has-background-warning'
}
}
ThisPage.methods.consumerShortList = function(row) { ThisPage.methods.consumerShortList = function(row) {
let keys = [] let keys = []
if (row.watcher_consumes_self) { if (row.watcher_consumes_self) {
@ -680,16 +739,9 @@
this.editingProfilePendingConsumers = [] this.editingProfilePendingConsumers = []
for (let consumer of row.consumers_data) { for (let consumer of row.consumers_data) {
let pending = { const pending = {
...consumer,
original_key: consumer.key, original_key: consumer.key,
key: consumer.key,
consumer_spec: consumer.consumer_spec,
consumer_dbkey: consumer.consumer_dbkey,
consumer_delay: consumer.consumer_delay,
consumer_retry_attempts: consumer.consumer_retry_attempts,
consumer_retry_delay: consumer.consumer_retry_delay,
consumer_runas: consumer.consumer_runas,
enabled: consumer.enabled,
} }
this.editingProfilePendingConsumers.push(pending) this.editingProfilePendingConsumers.push(pending)
} }
@ -737,8 +789,8 @@
this.editingProfilePendingWatcherKwargs.splice(i, 1) this.editingProfilePendingWatcherKwargs.splice(i, 1)
} }
ThisPage.methods.findOriginalConsumer = function(key) { ThisPage.methods.findConsumer = function(profileConsumers, key) {
for (let consumer of this.editingProfile.consumers_data) { for (const consumer of profileConsumers) {
if (consumer.key == key) { if (consumer.key == key) {
return consumer return consumer
} }
@ -746,11 +798,15 @@
} }
ThisPage.methods.updateProfile = function() { ThisPage.methods.updateProfile = function() {
let row = this.editingProfile const row = this.editingProfile
if (!row.key) { const newRow = !row.key
let originalProfile = null
if (newRow) {
row.consumers_data = [] row.consumers_data = []
this.profilesData.push(row) this.profilesData.push(row)
} else {
originalProfile = this.findProfile(row)
} }
row.key = this.editingProfileKey row.key = this.editingProfileKey
@ -798,7 +854,8 @@
for (let pending of this.editingProfilePendingConsumers) { for (let pending of this.editingProfilePendingConsumers) {
persistentConsumers.push(pending.key) persistentConsumers.push(pending.key)
if (pending.original_key) { if (pending.original_key) {
let consumer = this.findOriginalConsumer(pending.original_key) const consumer = this.findConsumer(originalProfile.consumers_data,
pending.original_key)
consumer.key = pending.key consumer.key = pending.key
consumer.consumer_spec = pending.consumer_spec consumer.consumer_spec = pending.consumer_spec
consumer.consumer_dbkey = pending.consumer_dbkey consumer.consumer_dbkey = pending.consumer_dbkey
@ -825,10 +882,31 @@
row.consumers_data.splice(i, 1) row.consumers_data.splice(i, 1)
} }
if (!newRow) {
// nb. must explicitly update the original data row;
// otherwise (with vue3) it will remain stale and
// submitting the form will keep same settings!
// TODO: this probably means i am doing something
// sloppy, but at least this hack fixes for now.
const profile = this.findProfile(row)
for (const key of Object.keys(row)) {
profile[key] = row[key]
}
}
this.settingsNeedSaved = true this.settingsNeedSaved = true
this.editProfileShowDialog = false this.editProfileShowDialog = false
} }
ThisPage.methods.findProfile = function(row) {
for (const profile of this.profilesData) {
if (profile.key == row.key) {
return profile
}
}
}
ThisPage.methods.deleteProfile = function(row) { ThisPage.methods.deleteProfile = function(row) {
if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) { if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) {
let i = this.profilesData.indexOf(row) let i = this.profilesData.indexOf(row)
@ -865,8 +943,10 @@
} }
ThisPage.methods.updateConsumer = function() { ThisPage.methods.updateConsumer = function() {
let pending = this.editingConsumer const pending = this.findConsumer(
let isNew = !pending.key this.editingProfilePendingConsumers,
this.editingConsumer.key)
const isNew = !pending.key
pending.key = this.editingConsumerKey pending.key = this.editingConsumerKey
pending.consumer_spec = this.editingConsumerSpec pending.consumer_spec = this.editingConsumerSpec
@ -907,6 +987,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -5,13 +5,6 @@
<%def name="content_title()"></%def> <%def name="content_title()"></%def>
<%def name="context_menu_items()">
${parent.context_menu_items()}
% if request.has_perm('datasync_changes.list'):
<li>${h.link_to("View DataSync Changes", url('datasyncchanges'))}</li>
% endif
</%def>
<%def name="page_content()"> <%def name="page_content()">
% if expose_websockets and not supervisor_error: % if expose_websockets and not supervisor_error:
<b-notification type="is-warning" <b-notification type="is-warning"
@ -47,83 +40,84 @@
</div> </div>
</b-field> </b-field>
<b-field label="Watcher Status"> <h3 class="is-size-3">Watcher Status</h3>
<b-table :data="watchers">
<b-table-column field="key" <${b}-table :data="watchers">
<${b}-table-column field="key"
label="Watcher" label="Watcher"
v-slot="props"> v-slot="props">
{{ props.row.key }} {{ props.row.key }}
</b-table-column> </${b}-table-column>
<b-table-column field="spec" <${b}-table-column field="spec"
label="Spec" label="Spec"
v-slot="props"> v-slot="props">
{{ props.row.spec }} {{ props.row.spec }}
</b-table-column> </${b}-table-column>
<b-table-column field="dbkey" <${b}-table-column field="dbkey"
label="DB Key" label="DB Key"
v-slot="props"> v-slot="props">
{{ props.row.dbkey }} {{ props.row.dbkey }}
</b-table-column> </${b}-table-column>
<b-table-column field="delay" <${b}-table-column field="delay"
label="Delay" label="Delay"
v-slot="props"> v-slot="props">
{{ props.row.delay }} second(s) {{ props.row.delay }} second(s)
</b-table-column> </${b}-table-column>
<b-table-column field="lastrun" <${b}-table-column field="lastrun"
label="Last Watched" label="Last Watched"
v-slot="props"> v-slot="props">
<span v-html="props.row.lastrun"></span> <span v-html="props.row.lastrun"></span>
</b-table-column> </${b}-table-column>
<b-table-column field="status" <${b}-table-column field="status"
label="Status" label="Status"
v-slot="props"> v-slot="props">
<span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'">
{{ props.row.status }} {{ props.row.status }}
</span> </span>
</b-table-column> </${b}-table-column>
</b-table> </${b}-table>
</b-field>
<b-field label="Consumer Status"> <h3 class="is-size-3">Consumer Status</h3>
<b-table :data="consumers">
<b-table-column field="key" <${b}-table :data="consumers">
<${b}-table-column field="key"
label="Consumer" label="Consumer"
v-slot="props"> v-slot="props">
{{ props.row.key }} {{ props.row.key }}
</b-table-column> </${b}-table-column>
<b-table-column field="spec" <${b}-table-column field="spec"
label="Spec" label="Spec"
v-slot="props"> v-slot="props">
{{ props.row.spec }} {{ props.row.spec }}
</b-table-column> </${b}-table-column>
<b-table-column field="dbkey" <${b}-table-column field="dbkey"
label="DB Key" label="DB Key"
v-slot="props"> v-slot="props">
{{ props.row.dbkey }} {{ props.row.dbkey }}
</b-table-column> </${b}-table-column>
<b-table-column field="delay" <${b}-table-column field="delay"
label="Delay" label="Delay"
v-slot="props"> v-slot="props">
{{ props.row.delay }} second(s) {{ props.row.delay }} second(s)
</b-table-column> </${b}-table-column>
<b-table-column field="changes" <${b}-table-column field="changes"
label="Pending Changes" label="Pending Changes"
v-slot="props"> v-slot="props">
{{ props.row.changes }} {{ props.row.changes }}
</b-table-column> </${b}-table-column>
<b-table-column field="status" <${b}-table-column field="status"
label="Status" label="Status"
v-slot="props"> v-slot="props">
<span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'"> <span :class="props.row.status == 'okay' ? 'has-background-success' : 'has-background-warning'">
{{ props.row.status }} {{ props.row.status }}
</span> </span>
</b-table-column> </${b}-table-column>
</b-table> </${b}-table>
</b-field>
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
<script type="text/javascript"> ${parent.modify_vue_vars()}
<script>
ThisPageData.processInfo = ${json.dumps(process_info)|n} ThisPageData.processInfo = ${json.dumps(process_info)|n}
@ -178,6 +172,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -1,6 +1,7 @@
<div i18n:domain="deform" tal:omit-tag="" <div i18n:domain="deform" tal:omit-tag=""
tal:define="oid oid|field.oid; tal:define="oid oid|field.oid;
name name|field.name; name name|field.name;
vmodel vmodel|'field_model_' + name;
css_class css_class|field.widget.css_class; css_class css_class|field.widget.css_class;
style style|field.widget.style;"> style style|field.widget.style;">
@ -8,7 +9,7 @@
${field.start_mapping()} ${field.start_mapping()}
<b-input type="password" <b-input type="password"
name="${name}" name="${name}"
value="${field.widget.redisplay and cstruct or ''}" v-model="${vmodel}"
tal:attributes="class string: form-control ${css_class or ''}; tal:attributes="class string: form-control ${css_class or ''};
style style; style style;
attributes|field.widget.attributes|{};" attributes|field.widget.attributes|{};"
@ -18,7 +19,6 @@
</b-input> </b-input>
<b-input type="password" <b-input type="password"
name="${name}-confirm" name="${name}-confirm"
value="${field.widget.redisplay and confirm or ''}"
tal:attributes="class string: form-control ${css_class or ''}; tal:attributes="class string: form-control ${css_class or ''};
style style; style style;
confirm_attributes|field.widget.confirm_attributes|{};" confirm_attributes|field.widget.confirm_attributes|{};"

View file

@ -2,11 +2,14 @@
<tal:block tal:define="oid oid|field.oid; <tal:block tal:define="oid oid|field.oid;
css_class css_class|field.widget.css_class; css_class css_class|field.widget.css_class;
style style|field.widget.style; style style|field.widget.style;
field_name field_name|field.name;"> field_name field_name|field.name;
use_oruga use_oruga;">
<div tal:define="vmodel vmodel|'field_model_' + field_name;"> <div tal:define="vmodel vmodel|'field_model_' + field_name;">
${field.start_mapping()} ${field.start_mapping()}
<b-field class="file">
<b-field class="file"
tal:condition="not use_oruga">
<b-upload name="upload" <b-upload name="upload"
v-model="${vmodel}"> v-model="${vmodel}">
<a class="button is-primary"> <a class="button is-primary">
@ -18,6 +21,23 @@
{{ ${vmodel}.name }} {{ ${vmodel}.name }}
</span> </span>
</b-field> </b-field>
<o-field class="file"
tal:condition="use_oruga">
<o-upload name="upload"
v-slot="{ onclick }"
v-model="${vmodel}">
<o-button variant="primary"
@click="onclick">
<o-icon icon="upload" />
<span>Click to upload</span>
</o-button>
</o-upload>
<span class="file-name" v-if="${vmodel}">
{{ ${vmodel}.name }}
</span>
</o-field>
${field.end_mapping()} ${field.end_mapping()}
</div> </div>

View file

@ -1,13 +1,9 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" /> <%inherit file="/master/view.mako" />
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n}
${form.component_studly}Data.employeesData = ${json.dumps(employees_data)|n}
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -6,20 +6,63 @@
<%def name="render_form_buttons()"></%def> <%def name="render_form_buttons()"></%def>
<%def name="render_form_template()"> <%def name="render_form_template()">
${form.render_deform(buttons=capture(self.render_form_buttons))|n} ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n}
</%def> </%def>
<%def name="render_form()"> <%def name="render_form()">
<div class="form"> <div class="form">
${form.render_vuejs_component()} ${form.render_vue_tag()}
</div> </div>
</%def> </%def>
<%def name="page_content()"> <%def name="page_content()">
<div class="form-wrapper"> % if main_form_collapsible:
<br /> <${b}-collapse class="panel"
${self.render_form()} % if request.use_oruga:
</div> v-model:open="mainFormPanelOpen"
% else:
:open.sync="mainFormPanelOpen"
% endif
>
<template #trigger="props">
<div class="panel-heading"
role="button"
style="cursor: pointer;">
## TODO: for some reason buefy will "reuse" the icon
## element in such a way that its display does not
## refresh. so to work around that, we use different
## structure for the two icons, so buefy is forced to
## re-draw
<b-icon v-if="props.open"
pack="fas"
icon="caret-down">
</b-icon>
<span v-if="!props.open">
<b-icon pack="fas"
icon="caret-right">
</b-icon>
</span>
&nbsp;
<strong>${main_form_title}</strong>
</div>
</template>
<div class="panel-block">
<div class="form-wrapper">
<br />
${self.render_form()}
</div>
</div>
</${b}-collapse>
% else:
<div class="form-wrapper">
<br />
${self.render_form()}
</div>
% endif
</%def> </%def>
<%def name="render_this_page()"> <%def name="render_this_page()">
@ -47,25 +90,25 @@
<%def name="before_object_helpers()"></%def> <%def name="before_object_helpers()"></%def>
<%def name="render_this_page_template()"> <%def name="render_vue_templates()">
${parent.render_vue_templates()}
% if form is not Undefined: % if form is not Undefined:
${self.render_form_template()} ${self.render_form_template()}
% endif % endif
${parent.render_this_page_template()}
</%def> </%def>
<%def name="finalize_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.finalize_this_page_vars()} ${parent.modify_vue_vars()}
% if form is not Undefined: % if main_form_collapsible:
<script type="text/javascript"> <script>
ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'}
${form.component_studly}.data = function() { return ${form.component_studly}Data }
Vue.component('${form.component}', ${form.component_studly})
</script> </script>
% endif % endif
</%def> </%def>
<%def name="make_vue_components()">
${parent.body()} ${parent.make_vue_components()}
% if form is not Undefined:
${form.render_vue_finalize()}
% endif
</%def>

View file

@ -39,7 +39,7 @@
simplePOST(action, params, success, failure) { simplePOST(action, params, success, failure) {
let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} let csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
let headers = { let headers = {
'${csrf_header_name}': csrftoken, '${csrf_header_name}': csrftoken,

View file

@ -1,17 +1,19 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<script type="text/x-template" id="${form.component}-template"> <% request.register_component(form.vue_tagname, form.vue_component) %>
<script type="text/x-template" id="${form.vue_tagname}-template">
<div> <div>
% if not form.readonly: % if not form.readonly:
${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)} ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **(form_kwargs or {}))}
${h.csrf_token(request)} ${h.csrf_token(request)}
% endif % endif
<section> <section>
% if form_body is not Undefined and form_body: % if form_body is not Undefined and form_body:
${form_body|n} ${form_body|n}
% elif form.grouping: % elif getattr(form, 'grouping', None):
% for group in form.grouping: % for group in form.grouping:
<nav class="panel"> <nav class="panel">
<p class="panel-heading">${group}</p> <p class="panel-heading">${group}</p>
@ -25,8 +27,8 @@
</nav> </nav>
% endfor % endfor
% else: % else:
% for field in form.fields: % for fieldname in form.fields:
${form.render_field_complete(field)} ${form.render_vue_field(fieldname, session=session)}
% endfor % endfor
% endif % endif
</section> </section>
@ -52,16 +54,20 @@
<input type="reset" value="Reset" class="button" /> <input type="reset" value="Reset" class="button" />
% endif % endif
## TODO: deprecate / remove the latter option here ## TODO: deprecate / remove the latter option here
% if form.auto_disable_save or form.auto_disable: % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
<b-button type="is-primary" <b-button type="is-primary"
native-type="submit" native-type="submit"
:disabled="${form.component_studly}Submitting"> :disabled="${form.vue_component}Submitting"
{{ ${form.component_studly}ButtonText }} icon-pack="fas"
icon-left="${form.button_icon_submit}">
{{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }}
</b-button> </b-button>
% else: % else:
<b-button type="is-primary" <b-button type="is-primary"
native-type="submit"> native-type="submit"
${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))} icon-pack="fas"
icon-left="save">
${form.button_label_submit}
</b-button> </b-button>
% endif % endif
</div> </div>
@ -116,8 +122,8 @@
<script type="text/javascript"> <script type="text/javascript">
let ${form.component_studly} = { let ${form.vue_component} = {
template: '#${form.component}-template', template: '#${form.vue_tagname}-template',
mixins: [FormPosterMixin], mixins: [FormPosterMixin],
components: {}, components: {},
props: { props: {
@ -130,10 +136,9 @@
methods: { methods: {
## TODO: deprecate / remove the latter option here ## TODO: deprecate / remove the latter option here
% if form.auto_disable_save or form.auto_disable: % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
submit${form.component_studly}() { submit${form.vue_component}() {
this.${form.component_studly}Submitting = true this.${form.vue_component}Submitting = true
this.${form.component_studly}ButtonText = "Working, please wait..."
}, },
% endif % endif
@ -172,10 +177,10 @@
} }
} }
let ${form.component_studly}Data = { let ${form.vue_component}Data = {
## TODO: should find a better way to handle CSRF token ## TODO: should find a better way to handle CSRF token
csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
% if can_edit_help: % if can_edit_help:
fieldLabels: ${json.dumps(field_labels)|n}, fieldLabels: ${json.dumps(field_labels)|n},
@ -192,16 +197,14 @@
% if not form.readonly: % if not form.readonly:
% for field in form.fields: % for field in form.fields:
% if field in dform: % if field in dform:
<% field = dform[field] %> field_model_${field}: ${json.dumps(form.get_vue_field_value(field))|n},
field_model_${field.name}: ${form.get_vuejs_model_value(field)|n},
% endif % endif
% endfor % endfor
% endif % endif
## TODO: deprecate / remove the latter option here ## TODO: deprecate / remove the latter option here
% if form.auto_disable_save or form.auto_disable: % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable:
${form.component_studly}Submitting: false, ${form.vue_component}Submitting: false,
${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n},
% endif % endif
} }

View file

@ -0,0 +1,3 @@
## -*- coding: utf-8; -*-
<%inherit file="/forms/deform.mako" />
${parent.body()}

View file

@ -87,7 +87,7 @@
<div class="level-item"> <div class="level-item">
<b-button type="is-primary" <b-button type="is-primary"
icon-pack="fas" icon-pack="fas"
icon-left="fas fa-plus" icon-left="plus"
@click="addColumn()"> @click="addColumn()">
New Column New Column
</b-button> </b-button>
@ -97,7 +97,7 @@
<div class="level-item"> <div class="level-item">
<b-button type="is-danger" <b-button type="is-danger"
icon-pack="fas" icon-pack="fas"
icon-left="fas fa-trash" icon-left="trash"
@click="new_table.columns = []" @click="new_table.columns = []"
:disabled="!new_table.columns.length"> :disabled="!new_table.columns.length">
Delete All Delete All
@ -106,55 +106,68 @@
</div> </div>
</div> </div>
<b-table <${b}-table
:data="new_table.columns"> :data="new_table.columns">
<b-table-column field="name" <${b}-table-column field="name"
label="Name" label="Name"
v-slot="props"> v-slot="props">
{{ props.row.name }} {{ props.row.name }}
</b-table-column> </${b}-table-column>
<b-table-column field="data_type" <${b}-table-column field="data_type"
label="Data Type" label="Data Type"
v-slot="props"> v-slot="props">
{{ props.row.data_type }} {{ props.row.data_type }}
</b-table-column> </${b}-table-column>
<b-table-column field="nullable" <${b}-table-column field="nullable"
label="Nullable" label="Nullable"
v-slot="props"> v-slot="props">
{{ props.row.nullable }} {{ props.row.nullable }}
</b-table-column> </${b}-table-column>
<b-table-column field="description" <${b}-table-column field="description"
label="Description" label="Description"
v-slot="props"> v-slot="props">
{{ props.row.description }} {{ props.row.description }}
</b-table-column> </${b}-table-column>
<b-table-column field="actions" <${b}-table-column field="actions"
label="Actions" label="Actions"
v-slot="props"> v-slot="props">
<a href="#" class="grid-action" <a href="#" class="grid-action"
@click.prevent="editColumnRow(props.row)"> @click.prevent="editColumnRow(props)">
<i class="fas fa-edit"></i> % if request.use_oruga:
<o-icon icon="edit" />
% else:
<i class="fas fa-edit"></i>
% endif
Edit Edit
</a> </a>
&nbsp; &nbsp;
<a href="#" class="grid-action has-text-danger" <a href="#" class="grid-action has-text-danger"
@click.prevent="deleteColumn(props.index)"> @click.prevent="deleteColumn(props.index)">
<i class="fas fa-trash"></i> % if request.use_oruga:
<o-icon icon="trash" />
% else:
<i class="fas fa-trash"></i>
% endif
Delete Delete
</a> </a>
&nbsp; &nbsp;
</b-table-column> </${b}-table-column>
</b-table> </${b}-table>
<b-modal has-modal-card <${b}-modal has-modal-card
:active.sync="showingEditColumn"> % if request.use_oruga:
v-model:active="showingEditColumn"
% else:
:active.sync="showingEditColumn"
% endif
>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
@ -164,11 +177,13 @@
<section class="modal-card-body"> <section class="modal-card-body">
<b-field label="Name"> <b-field label="Name">
<b-input v-model="editingColumnName"></b-input> <b-input v-model="editingColumnName"
expanded />
</b-field> </b-field>
<b-field label="Data Type"> <b-field label="Data Type">
<b-input v-model="editingColumnDataType"></b-input> <b-input v-model="editingColumnDataType"
expanded />
</b-field> </b-field>
<b-field label="Nullable"> <b-field label="Nullable">
@ -179,7 +194,8 @@
</b-field> </b-field>
<b-field label="Description"> <b-field label="Description">
<b-input v-model="editingColumnDescription"></b-input> <b-input v-model="editingColumnDescription"
expanded />
</b-field> </b-field>
</section> </section>
@ -194,7 +210,7 @@
</b-button> </b-button>
</footer> </footer>
</div> </div>
</b-modal> </${b}-modal>
</div> </div>
</b-field> </b-field>
@ -260,9 +276,9 @@
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
ThisPageData.featureType = ${json.dumps(feature_type)|n} ThisPageData.featureType = ${json.dumps(feature_type)|n}
ThisPageData.resultGenerated = ${json.dumps(bool(result))|n} ThisPageData.resultGenerated = ${json.dumps(bool(result))|n}
@ -280,7 +296,7 @@
% endfor % endfor
} }
% for key, form in six.iteritems(feature_forms): % for key, form in feature_forms.items():
<% safekey = key.replace('-', '_') %> <% safekey = key.replace('-', '_') %>
ThisPageData.${safekey} = { ThisPageData.${safekey} = {
<% dform = feature_forms[key].make_deform_form() %> <% dform = feature_forms[key].make_deform_form() %>
@ -315,6 +331,7 @@
ThisPageData.showingEditColumn = false ThisPageData.showingEditColumn = false
ThisPageData.editingColumn = null ThisPageData.editingColumn = null
ThisPageData.editingColumnIndex = null
ThisPageData.editingColumnName = null ThisPageData.editingColumnName = null
ThisPageData.editingColumnDataType = null ThisPageData.editingColumnDataType = null
ThisPageData.editingColumnNullable = null ThisPageData.editingColumnNullable = null
@ -322,6 +339,7 @@
ThisPage.methods.addColumn = function(column) { ThisPage.methods.addColumn = function(column) {
this.editingColumn = null this.editingColumn = null
this.editingColumnIndex = null
this.editingColumnName = null this.editingColumnName = null
this.editingColumnDataType = null this.editingColumnDataType = null
this.editingColumnNullable = true this.editingColumnNullable = true
@ -329,8 +347,10 @@
this.showingEditColumn = true this.showingEditColumn = true
} }
ThisPage.methods.editColumnRow = function(column) { ThisPage.methods.editColumnRow = function(props) {
const column = props.row
this.editingColumn = column this.editingColumn = column
this.editingColumnIndex = props.index
this.editingColumnName = column.name this.editingColumnName = column.name
this.editingColumnDataType = column.data_type this.editingColumnDataType = column.data_type
this.editingColumnNullable = column.nullable this.editingColumnNullable = column.nullable
@ -340,7 +360,7 @@
ThisPage.methods.saveColumn = function() { ThisPage.methods.saveColumn = function() {
if (this.editingColumn) { if (this.editingColumn) {
column = this.editingColumn column = this.new_table.columns[this.editingColumnIndex]
} else { } else {
column = {} column = {}
this.new_table.columns.push(column) this.new_table.columns.push(column)
@ -365,6 +385,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -8,7 +8,8 @@
<%def name="page_content()"> <%def name="page_content()">
% if project_type: % if project_type:
<b-field grouped> <b-field grouped>
<b-field horizontal expanded label="Project Type"> <b-field horizontal expanded label="Project Type"
class="is-expanded">
${project_type} ${project_type}
</b-field> </b-field>
<once-button type="is-primary" <once-button type="is-primary"

View file

@ -1,5 +1,5 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<b-table <${b}-table
:data="${data_prop}" :data="${data_prop}"
icon-pack="fas" icon-pack="fas"
striped striped
@ -21,7 +21,7 @@
> >
% for i, column in enumerate(grid_columns): % for i, column in enumerate(grid_columns):
<b-table-column field="${column['field']}" <${b}-table-column field="${column['field']}"
% if not empty_labels: % if not empty_labels:
label="${column['label']}" label="${column['label']}"
% elif i > 0: % elif i > 0:
@ -50,14 +50,14 @@
% else: % else:
<span v-html="props.row.${column['field']}"></span> <span v-html="props.row.${column['field']}"></span>
% endif % endif
</b-table-column> </${b}-table-column>
% endfor % endfor
% if grid.main_actions or grid.more_actions: % if grid.actions:
<b-table-column field="actions" <${b}-table-column field="actions"
label="Actions" label="Actions"
v-slot="props"> v-slot="props">
% for action in grid.main_actions: % for action in grid.actions:
<a :href="props.row._action_url_${action.key}" <a :href="props.row._action_url_${action.key}"
% if action.link_class: % if action.link_class:
class="${action.link_class}" class="${action.link_class}"
@ -68,20 +68,19 @@
@click.prevent="${action.click_handler}" @click.prevent="${action.click_handler}"
% endif % endif
> >
<i class="fas fa-${action.icon}"></i> ${action.render_icon_and_label()}
${action.label}
</a> </a>
&nbsp; &nbsp;
% endfor % endfor
</b-table-column> </${b}-table-column>
% endif % endif
<template slot="empty"> <template #empty>
<div class="content has-text-grey has-text-centered"> <div class="content has-text-grey has-text-centered">
<p> <p>
<b-icon <b-icon
pack="fas" pack="fas"
icon="fas fa-sad-tear" icon="sad-tear"
size="is-large"> size="is-large">
</b-icon> </b-icon>
</p> </p>
@ -99,4 +98,4 @@
</template> </template>
% endif % endif
</b-table> </${b}-table>

View file

@ -1,142 +1,79 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<script type="text/x-template" id="grid-filter-numeric-value-template"> <% request.register_component(grid.vue_tagname, grid.vue_component) %>
<div class="level">
<div class="level-left">
<div class="level-item">
<b-input v-model="startValue"
ref="startValue"
@input="startValueChanged">
</b-input>
</div>
<div v-show="wantsRange"
class="level-item">
and
</div>
<div v-show="wantsRange"
class="level-item">
<b-input v-model="endValue"
ref="endValue"
@input="endValueChanged">
</b-input>
</div>
</div>
</div>
</script>
<script type="text/x-template" id="grid-filter-date-value-template"> <script type="text/x-template" id="${grid.vue_tagname}-template">
<div class="level">
<div class="level-left">
<div class="level-item">
<tailbone-datepicker v-model="startDate"
ref="startDate"
@input="startDateChanged">
</tailbone-datepicker>
</div>
<div v-show="dateRange"
class="level-item">
and
</div>
<div v-show="dateRange"
class="level-item">
<tailbone-datepicker v-model="endDate"
ref="endDate"
@input="endDateChanged">
</tailbone-datepicker>
</div>
</div>
</div>
</script>
<script type="text/x-template" id="grid-filter-template">
<div class="level filter" v-show="filter.visible">
<div class="level-left"
style="align-items: start;">
<div class="level-item filter-fieldname">
<b-field>
<b-checkbox-button v-model="filter.active" native-value="IGNORED">
<b-icon pack="fas" icon="check" v-show="filter.active"></b-icon>
<span>{{ filter.label }}</span>
</b-checkbox-button>
</b-field>
</div>
<b-field grouped v-show="filter.active"
class="level-item"
style="align-items: start;">
<b-select v-model="filter.verb"
@input="focusValue()"
class="filter-verb">
<option v-for="verb in filter.verbs"
:key="verb"
:value="verb">
{{ filter.verb_labels[verb] }}
</option>
</b-select>
## only one of the following "value input" elements will be rendered
<grid-filter-date-value v-if="filter.data_type == 'date'"
v-model="filter.value"
v-show="valuedVerb()"
:date-range="filter.verb == 'between'"
ref="valueInput">
</grid-filter-date-value>
<b-select v-if="filter.data_type == 'choice'"
v-model="filter.value"
v-show="valuedVerb()"
ref="valueInput">
<option v-for="choice in filter.choices"
:key="choice"
:value="choice">
{{ filter.choice_labels[choice] || choice }}
</option>
</b-select>
<grid-filter-numeric-value v-if="filter.data_type == 'number'"
v-model="filter.value"
v-show="valuedVerb()"
:wants-range="filter.verb == 'between'"
ref="valueInput">
</grid-filter-numeric-value>
<b-input v-if="filter.data_type == 'string' && !multiValuedVerb()"
v-model="filter.value"
v-show="valuedVerb()"
ref="valueInput">
</b-input>
<b-input v-if="filter.data_type == 'string' && multiValuedVerb()"
type="textarea"
v-model="filter.value"
v-show="valuedVerb()"
ref="valueInput">
</b-input>
</b-field>
</div><!-- level-left -->
</div><!-- level -->
</script>
<script type="text/x-template" id="${grid.component}-template">
<div> <div>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;"> <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
<div style="display: flex; flex-direction: column; justify-content: space-between;"> <div style="display: flex; flex-direction: column; justify-content: end;">
<div></div>
<div class="filters"> <div class="filters">
% if grid.filterable: % if getattr(grid, 'filterable', False):
## TODO: stop using |n filter <form method="GET" @submit.prevent="applyFilters()">
${grid.render_filters(allow_save_defaults=allow_save_defaults)|n}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<grid-filter v-for="key in filtersSequence"
:key="key"
:filter="filters[key]"
ref="gridFilters">
</grid-filter>
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<b-button type="is-primary"
native-type="submit"
icon-pack="fas"
icon-left="check">
Apply Filters
</b-button>
<b-button v-if="!addFilterShow"
icon-pack="fas"
icon-left="plus"
@click="addFilterInit()">
Add Filter
</b-button>
<b-autocomplete v-if="addFilterShow"
ref="addFilterAutocomplete"
:data="addFilterChoices"
v-model="addFilterTerm"
placeholder="Add Filter"
field="key"
:custom-formatter="formatAddFilterItem"
open-on-focus
keep-first
icon-pack="fas"
clearable
clear-on-select
@select="addFilterSelect">
</b-autocomplete>
<b-button @click="resetView()"
icon-pack="fas"
icon-left="home">
Default View
</b-button>
<b-button @click="clearFilters()"
icon-pack="fas"
icon-left="trash">
No Filters
</b-button>
% if allow_save_defaults and request.user:
<b-button @click="saveDefaults()"
icon-pack="fas"
icon-left="save"
:disabled="savingDefaults">
{{ savingDefaults ? "Working, please wait..." : "Save Defaults" }}
</b-button>
% endif
</div>
</form>
% endif % endif
</div> </div>
</div> </div>
@ -154,7 +91,7 @@
<div class="grid-tools-wrapper"> <div class="grid-tools-wrapper">
% if tools: % if tools:
<div class="grid-tools field buttons is-grouped is-pulled-right"> <div class="grid-tools">
## TODO: stop using |n filter ## TODO: stop using |n filter
${tools|n} ${tools|n}
</div> </div>
@ -165,11 +102,13 @@
</div> </div>
<b-table <${b}-table
:data="visibleData" :data="visibleData"
## :columns="columns"
:loading="loading" :loading="loading"
:row-class="getRowClass" :row-class="getRowClass"
% if request.use_oruga:
tr-checked-class="is-checked"
% endif
% if request.rattail_config.getbool('tailbone', 'sticky_headers'): % if request.rattail_config.getbool('tailbone', 'sticky_headers'):
sticky-header sticky-header
@ -178,58 +117,75 @@
:checkable="checkable" :checkable="checkable"
% if grid.checkboxes: % if getattr(grid, 'checkboxes', False):
:checked-rows.sync="checkedRows" % if request.use_oruga:
% if grid.clicking_row_checks_box: v-model:checked-rows="checkedRows"
@click="rowClick" % else:
% endif :checked-rows.sync="checkedRows"
% endif
% if grid.clicking_row_checks_box:
@click="rowClick"
% endif
% endif % endif
% if grid.check_handler: % if getattr(grid, 'check_handler', None):
@check="${grid.check_handler}" @check="${grid.check_handler}"
% endif % endif
% if grid.check_all_handler: % if getattr(grid, 'check_all_handler', None):
@check-all="${grid.check_all_handler}" @check-all="${grid.check_all_handler}"
% endif % endif
% if hasattr(grid, 'checkable'):
% if isinstance(grid.checkable, str): % if isinstance(grid.checkable, str):
:is-row-checkable="${grid.row_checkable}" :is-row-checkable="${grid.row_checkable}"
% elif grid.checkable: % elif grid.checkable:
:is-row-checkable="row => row._checkable" :is-row-checkable="row => row._checkable"
% endif % endif
% if grid.sortable:
backend-sorting
@sort="onSort"
@sorting-priority-removed="sortingPriorityRemoved"
## TODO: there is a bug (?) which prevents the arrow from
## displaying for simple default single-column sort. so to
## work around that, we *disable* multi-sort until the
## component is mounted. seems to work for now..see also
## https://github.com/buefy/buefy/issues/2584
:sort-multiple="allowMultiSort"
## nb. specify default sort only if single-column
:default-sort="backendSorters.length == 1 ? [backendSorters[0].field, backendSorters[0].order] : null"
## nb. otherwise there may be default multi-column sort
:sort-multiple-data="sortingPriority"
## user must ctrl-click column header to do multi-sort
sort-multiple-key="ctrlKey"
% endif % endif
% if grid.click_handlers: ## sorting
% if grid.sortable:
## nb. buefy/oruga only support *one* default sorter
:default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null"
% if grid.sort_on_backend:
backend-sorting
@sort="onSort"
% endif
% if grid.sort_multiple:
% if grid.sort_on_backend:
## TODO: there is a bug (?) which prevents the arrow
## from displaying for simple default single-column sort,
## when multi-column sort is allowed for the table. for
## now we work around that by waiting until mount to
## enable the multi-column support. see also
## https://github.com/buefy/buefy/issues/2584
:sort-multiple="allowMultiSort"
:sort-multiple-data="sortingPriority"
@sorting-priority-removed="sortingPriorityRemoved"
% else:
sort-multiple
% endif
## nb. user must ctrl-click column header for multi-sort
sort-multiple-key="ctrlKey"
% endif
% endif
% if getattr(grid, 'click_handlers', None):
@cellclick="cellClick" @cellclick="cellClick"
% endif % endif
:paginated="paginated" ## paging
:per-page="perPage" % if grid.paginated:
:current-page="currentPage" paginated
backend-pagination pagination-size="${'small' if request.use_oruga else 'is-small'}"
:total="total" :per-page="perPage"
@page-change="onPageChange" :current-page="currentPage"
@page-change="onPageChange"
% if grid.paginate_on_backend:
backend-pagination
:total="pagerStats.item_count"
% endif
% endif
## TODO: should let grid (or master view) decide how to set these? ## TODO: should let grid (or master view) decide how to set these?
icon-pack="fas" icon-pack="fas"
@ -238,17 +194,15 @@
:hoverable="true" :hoverable="true"
:narrowed="true"> :narrowed="true">
% for column in grid_columns: % for column in grid.get_vue_columns():
<b-table-column field="${column['field']}" <${b}-table-column field="${column['field']}"
label="${column['label']}" label="${column['label']}"
v-slot="props" v-slot="props"
:sortable="${json.dumps(column['sortable'])}" :sortable="${json.dumps(column.get('sortable', False))|n}"
% if grid.is_searchable(column['field']): :searchable="${json.dumps(column.get('searchable', False))|n}"
searchable
% endif
cell-class="c_${column['field']}" cell-class="c_${column['field']}"
:visible="${json.dumps(column['visible'])}"> :visible="${json.dumps(column.get('visible', True))}">
% if column['field'] in grid.raw_renderers: % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers:
${grid.raw_renderers[column['field']]()} ${grid.raw_renderers[column['field']]()}
% elif grid.is_linked(column['field']): % elif grid.is_linked(column['field']):
<a :href="props.row._action_url_view" <a :href="props.row._action_url_view"
@ -260,32 +214,31 @@
% else: % else:
<span v-html="props.row.${column['field']}"></span> <span v-html="props.row.${column['field']}"></span>
% endif % endif
</b-table-column> </${b}-table-column>
% endfor % endfor
% if grid.main_actions or grid.more_actions: % if grid.actions:
<b-table-column field="actions" <${b}-table-column field="actions"
label="Actions" label="Actions"
v-slot="props"> v-slot="props">
## TODO: we do not currently differentiate for "main vs. more" ## TODO: we do not currently differentiate for "main vs. more"
## here, but ideally we would tuck "more" away in a drawer etc. ## here, but ideally we would tuck "more" away in a drawer etc.
% for action in grid.main_actions + grid.more_actions: % for action in grid.actions:
<a v-if="props.row._action_url_${action.key}" <a v-if="props.row._action_url_${action.key}"
:href="props.row._action_url_${action.key}" :href="props.row._action_url_${action.key}"
class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}" class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}"
% if action.click_handler: % if getattr(action, 'click_handler', None):
@click.prevent="${action.click_handler}" @click.prevent="${action.click_handler}"
% endif % endif
% if action.target: % if getattr(action, 'target', None):
target="${action.target}" target="${action.target}"
% endif % endif
> >
${action.render_icon()|n} ${action.render_icon_and_label()}
${action.render_label()|n}
</a> </a>
&nbsp; &nbsp;
% endfor % endfor
</b-table-column> </${b}-table-column>
% endif % endif
<template #empty> <template #empty>
@ -294,7 +247,7 @@
<p> <p>
<b-icon <b-icon
pack="fas" pack="fas"
icon="fas fa-sad-tear" icon="sad-tear"
size="is-large"> size="is-large">
</b-icon> </b-icon>
</p> </p>
@ -306,43 +259,50 @@
<template #footer> <template #footer>
<div style="display: flex; justify-content: space-between;"> <div style="display: flex; justify-content: space-between;">
% if grid.expose_direct_link: % if getattr(grid, 'expose_direct_link', False):
<b-button type="is-primary" <b-button type="is-primary"
size="is-small" size="is-small"
@click="copyDirectLink()" @click="copyDirectLink()"
title="Copy link to clipboard"> title="Copy link to clipboard">
<span><i class="fa fa-share-alt"></i></span> % if request.use_oruga:
<o-icon icon="share-alt" />
% else:
<span><i class="fa fa-share-alt"></i></span>
% endif
</b-button> </b-button>
% else: % else:
<div></div> <div></div>
% endif % endif
% if grid.pageable: % if grid.paginated:
<b-field grouped <div v-if="pagerStats.first_item"
v-if="firstItem"> style="display: flex; gap: 0.5rem; align-items: center;">
<span class="control"> <span>
showing {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} of {{ total.toLocaleString('en') }} results; showing
{{ renderNumber(pagerStats.first_item) }}
- {{ renderNumber(pagerStats.last_item) }}
of {{ renderNumber(pagerStats.item_count) }} results;
</span> </span>
<b-select v-model="perPage" <b-select v-model="perPage"
size="is-small" size="is-small"
@input="loadAsyncData()"> @input="perPageUpdated">
% for value in grid.get_pagesize_options(): % for value in grid.get_pagesize_options():
<option value="${value}">${value}</option> <option value="${value}">${value}</option>
% endfor % endfor
</b-select> </b-select>
<span class="control"> <span>
per page per page
</span> </span>
</b-field> </div>
% endif % endif
</div> </div>
</template> </template>
</b-table> </${b}-table>
## dummy input field needed for sharing links on *insecure* sites ## dummy input field needed for sharing links on *insecure* sites
% if request.scheme == 'http': % if getattr(request, 'scheme', None) == 'http':
<b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input> <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input>
% endif % endif
@ -351,63 +311,72 @@
<script type="text/javascript"> <script type="text/javascript">
let ${grid.component_studly}CurrentData = ${json.dumps(grid_data['data'])|n} const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n}
let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data
let ${grid.component_studly}Data = { let ${grid.vue_component}Data = {
loading: false, loading: false,
ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n}, ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n},
data: ${grid.component_studly}CurrentData, ## nb. this tracks whether grid.fetchFirstData() happened
rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n}, fetchedFirstData: false,
checkable: ${json.dumps(grid.checkboxes)|n}, savingDefaults: false,
% if grid.checkboxes:
data: ${grid.vue_component}CurrentData,
rowStatusMap: ${json.dumps(grid_data['row_status_map'] if grid_data is not Undefined else {})|n},
checkable: ${json.dumps(getattr(grid, 'checkboxes', False))|n},
% if getattr(grid, 'checkboxes', False):
checkedRows: ${grid_data['checked_rows_code']|n}, checkedRows: ${grid_data['checked_rows_code']|n},
% endif % endif
paginated: ${json.dumps(grid.pageable)|n}, ## paging
total: ${len(grid_data['data']) if static_data else grid_data['total_items']}, % if grid.paginated:
perPage: ${json.dumps(grid.pagesize if grid.pageable else None)|n}, pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
currentPage: ${json.dumps(grid.page if grid.pageable else None)|n}, perPage: ${json.dumps(grid.pagesize)|n},
firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n}, currentPage: ${json.dumps(grid.page)|n},
lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n}, % if grid.paginate_on_backend:
pagerStats: ${json.dumps(grid.get_vue_pager_stats())|n},
% if grid.sortable:
## TODO: there is a bug (?) which prevents the arrow from
## displaying for simple default single-column sort. so to
## work around that, we *disable* multi-sort until the
## component is mounted. seems to work for now..see also
## https://github.com/buefy/buefy/issues/2584
allowMultiSort: false,
## nb. this contains all truly active sorters
backendSorters: ${json.dumps(grid.active_sorters)|n},
## nb. whereas this will only contain multi-column sorters,
## but will be *empty* for single-column sorting
% if len(grid.active_sorters) > 1:
sortingPriority: ${json.dumps(grid.active_sorters)|n},
% else:
sortingPriority: [],
% endif % endif
% endif
## sorting
% if grid.sortable:
sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
% if grid.sort_multiple:
% if grid.sort_on_backend:
## TODO: there is a bug (?) which prevents the arrow
## from displaying for simple default single-column sort,
## when multi-column sort is allowed for the table. for
## now we work around that by waiting until mount to
## enable the multi-column support. see also
## https://github.com/buefy/buefy/issues/2584
allowMultiSort: false,
## nb. this should be empty when current sort is single-column
% if len(grid.active_sorters) > 1:
sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n},
% else:
sortingPriority: [],
% endif
% endif
% endif
% endif % endif
## filterable: ${json.dumps(grid.filterable)|n}, ## filterable: ${json.dumps(grid.filterable)|n},
filters: ${json.dumps(filters_data if grid.filterable else None)|n}, filters: ${json.dumps(filters_data if getattr(grid, 'filterable', False) else None)|n},
filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n}, filtersSequence: ${json.dumps(filters_sequence if getattr(grid, 'filterable', False) else None)|n},
addFilterTerm: '', addFilterTerm: '',
addFilterShow: false, addFilterShow: false,
## dummy input value needed for sharing links on *insecure* sites ## dummy input value needed for sharing links on *insecure* sites
% if request.scheme == 'http': % if getattr(request, 'scheme', None) == 'http':
shareLink: null, shareLink: null,
% endif % endif
} }
let ${grid.component_studly} = { let ${grid.vue_component} = {
template: '#${grid.component}-template', template: '#${grid.vue_tagname}-template',
mixins: [FormPosterMixin], mixins: [FormPosterMixin],
@ -417,7 +386,34 @@
computed: { computed: {
## TODO: this should be temporary? but anyway 'total' is
## still referenced in other places, e.g. "delete results"
% if grid.paginated:
total() { return this.pagerStats.item_count },
% endif
% if not grid.paginate_on_backend:
pagerStats() {
const data = this.visibleData
let last = this.currentPage * this.perPage
let first = last - this.perPage + 1
if (last > data.length) {
last = data.length
}
return {
'item_count': data.length,
'items_per_page': this.perPage,
'page': this.currentPage,
'first_item': first,
'last_item': last,
}
},
% endif
addFilterChoices() { addFilterChoices() {
// nb. this returns all choices available for "Add Filter" operation
// collect all filters, which are *not* already shown // collect all filters, which are *not* already shown
let choices = [] let choices = []
@ -463,22 +459,40 @@
directLink() { directLink() {
let params = new URLSearchParams(this.getAllParams()) let params = new URLSearchParams(this.getAllParams())
return `${request.current_route_url(_query=None)}?${'$'}{params}` return `${request.path_url}?${'$'}{params}`
}, },
}, },
mounted() { % if grid.sortable and grid.sort_multiple and grid.sort_on_backend:
## TODO: there is a bug (?) which prevents the arrow from
## displaying for simple default single-column sort. so to ## TODO: there is a bug (?) which prevents the arrow
## work around that, we *disable* multi-sort until the ## from displaying for simple default single-column sort,
## component is mounted. seems to work for now..see also ## when multi-column sort is allowed for the table. for
## https://github.com/buefy/buefy/issues/2584 ## now we work around that by waiting until mount to
this.allowMultiSort = true ## enable the multi-column support. see also
}, ## https://github.com/buefy/buefy/issues/2584
mounted() {
this.allowMultiSort = true
},
% endif
methods: { methods: {
% if grid.click_handlers: renderNumber(value) {
if (value != undefined) {
return value.toLocaleString('en')
}
},
formatAddFilterItem(filtr) {
if (!filtr.key) {
filtr = this.filters[filtr]
}
return filtr.label || filtr.key
},
% if getattr(grid, 'click_handlers', None):
cellClick(row, column, rowIndex, columnIndex) { cellClick(row, column, rowIndex, columnIndex) {
% for key in grid.click_handlers: % for key in grid.click_handlers:
if (column._props.field == '${key}') { if (column._props.field == '${key}') {
@ -534,17 +548,18 @@
}, },
getBasicParams() { getBasicParams() {
let params = {} const params = {
% if grid.sortable: % if grid.paginated and grid.paginate_on_backend:
for (let i = 1; i <= this.backendSorters.length; i++) { pagesize: this.perPage,
params['sort'+i+'key'] = this.backendSorters[i-1].field page: this.currentPage,
params['sort'+i+'dir'] = this.backendSorters[i-1].order % endif
}
% if grid.sortable and grid.sort_on_backend:
for (let i = 1; i <= this.sorters.length; i++) {
params['sort'+i+'key'] = this.sorters[i-1].field
params['sort'+i+'dir'] = this.sorters[i-1].order
} }
% endif % endif
% if grid.pageable:
params.pagesize = this.perPage
params.page = this.currentPage
% endif
return params return params
}, },
@ -568,6 +583,17 @@
...this.getFilterParams()} ...this.getFilterParams()}
}, },
## nb. this is meant to call for a grid which is hidden at
## first, when it is first being shown to the user. and if
## it was initialized with empty data set.
async fetchFirstData() {
if (this.fetchedFirstData) {
return
}
await this.loadAsyncData()
this.fetchedFirstData = true
},
## TODO: i noticed buefy docs show using `async` keyword here, ## TODO: i noticed buefy docs show using `async` keyword here,
## so now i am too. knowing nothing at all of if/how this is ## so now i am too. knowing nothing at all of if/how this is
## supposed to improve anything. we shall see i guess ## supposed to improve anything. we shall see i guess
@ -575,40 +601,50 @@
if (params === undefined || params === null) { if (params === undefined || params === null) {
params = new URLSearchParams(this.getBasicParams()) params = new URLSearchParams(this.getBasicParams())
params.append('partial', true) } else {
params = params.toString() params = new URLSearchParams(params)
} }
if (!params.has('partial')) {
params.append('partial', true)
}
params = params.toString()
this.loading = true this.loading = true
this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => {
if (!data.error) { if (!response.data.error) {
${grid.component_studly}CurrentData = data.data ${grid.vue_component}CurrentData = response.data.data
this.data = ${grid.component_studly}CurrentData this.data = ${grid.vue_component}CurrentData
this.rowStatusMap = data.row_status_map % if grid.paginated and grid.paginate_on_backend:
this.total = data.total_items this.pagerStats = response.data.pager_stats
this.firstItem = data.first_item % endif
this.lastItem = data.last_item this.rowStatusMap = response.data.row_status_map || {}
this.loading = false this.loading = false
this.checkedRows = this.locateCheckedRows(data.checked_rows) this.savingDefaults = false
this.checkedRows = this.locateCheckedRows(response.data.checked_rows || [])
if (success) { if (success) {
success() success()
} }
} else { } else {
this.$buefy.toast.open({ this.$buefy.toast.open({
message: data.error, message: response.data.error,
type: 'is-danger', type: 'is-danger',
duration: 2000, // 4 seconds duration: 2000, // 4 seconds
}) })
this.loading = false this.loading = false
this.savingDefaults = false
if (failure) { if (failure) {
failure() failure()
} }
} }
}) })
.catch((error) => { .catch((error) => {
${grid.vue_component}CurrentData = []
this.data = [] this.data = []
this.total = 0 % if grid.paginated and grid.paginate_on_backend:
this.pagerStats = {}
% endif
this.loading = false this.loading = false
this.savingDefaults = false
if (failure) { if (failure) {
failure() failure()
} }
@ -633,50 +669,84 @@
this.loadAsyncData() this.loadAsyncData()
}, },
onSort(field, order, event) { perPageUpdated(value) {
if (event.ctrlKey) { // nb. buefy passes value, oruga passes event
if (value.target) {
// engage or enhance multi-column sorting value = event.target.value
let sorter = this.backendSorters.filter(i => i.field === field)[0]
if (sorter) {
sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
} else {
this.backendSorters.push({field, order})
}
this.sortingPriority = this.backendSorters
} else {
// sort by single column only
this.backendSorters = [{field, order}]
this.sortingPriority = []
} }
// always reset to first page when changing sort options this.loadAsyncData({
// TODO: i mean..right? would we ever not want that? pagesize: value,
this.currentPage = 1 })
this.loadAsyncData()
}, },
sortingPriorityRemoved(field) { % if grid.sortable and grid.sort_on_backend:
// prune field from active sorters onSort(field, order, event) {
this.backendSorters = this.backendSorters.filter(
(sorter) => sorter.field !== field)
// nb. must keep active sorter list "as-is" even if ## nb. buefy passes field name; oruga passes field object
// there is only one sorter; buefy seems to expect it % if request.use_oruga:
this.sortingPriority = this.backendSorters field = field.field
% endif
this.loadAsyncData() % if grid.sort_multiple:
},
// did user ctrl-click the column header?
if (event.ctrlKey) {
// toggle direction for existing, or add new sorter
const sorter = this.sorters.filter(s => s.field === field)[0]
if (sorter) {
sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
} else {
this.sorters.push({field, order})
}
// apply multi-column sorting
this.sortingPriority = this.sorters
} else {
% endif
// sort by single column only
this.sorters = [{field, order}]
% if grid.sort_multiple:
// multi-column sort not engaged
this.sortingPriority = []
}
% endif
// nb. always reset to first page when sorting changes
this.currentPage = 1
this.loadAsyncData()
},
% if grid.sort_multiple:
sortingPriorityRemoved(field) {
// prune from active sorters
this.sorters = this.sorters.filter(s => s.field !== field)
// nb. even though we might have just one sorter
// now, we are still technically in multi-sort mode
this.sortingPriority = this.sorters
this.loadAsyncData()
},
% endif
% endif
resetView() { resetView() {
this.loading = true this.loading = true
// use current url proper, plus reset param // use current url proper, plus reset param
let url = '?reset-to-default-filters=true' let url = '?reset-view=true'
// add current hash, to preserve that in redirect // add current hash, to preserve that in redirect
if (location.hash) { if (location.hash) {
@ -686,26 +756,34 @@
location.href = url location.href = url
}, },
addFilterButton(event) { addFilterInit() {
this.addFilterShow = true this.addFilterShow = true
this.$nextTick(() => { this.$nextTick(() => {
const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
input.addEventListener('keydown', this.addFilterKeydown)
this.$refs.addFilterAutocomplete.focus() this.$refs.addFilterAutocomplete.focus()
}) })
}, },
addFilterHide() {
const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
input.removeEventListener('keydown', this.addFilterKeydown)
this.addFilterTerm = ''
this.addFilterShow = false
},
addFilterKeydown(event) { addFilterKeydown(event) {
// ESC will clear searchbox // ESC will clear searchbox
if (event.which == 27) { if (event.which == 27) {
this.addFilterTerm = '' this.addFilterHide()
this.addFilterShow = false
} }
}, },
addFilterSelect(filtr) { addFilterSelect(filtr) {
this.addFilter(filtr.key) this.addFilter(filtr.key)
this.addFilterTerm = '' this.addFilterHide()
this.addFilterShow = false
}, },
addFilter(filter_key) { addFilter(filter_key) {
@ -805,6 +883,7 @@
}, },
saveDefaults() { saveDefaults() {
this.savingDefaults = true
// apply current filters as normal, but add special directive // apply current filters as normal, but add special directive
this.applyFilters({'save-current-filters-as-defaults': true}) this.applyFilters({'save-current-filters-as-defaults': true})
@ -841,7 +920,7 @@
} else { } else {
this.checkedRows.push(row) this.checkedRows.push(row)
} }
% if grid.check_handler: % if getattr(grid, 'check_handler', None):
this.${grid.check_handler}(this.checkedRows, row) this.${grid.check_handler}(this.checkedRows, row)
% endif % endif
}, },

View file

@ -0,0 +1,350 @@
## -*- coding: utf-8; -*-
<%def name="make_grid_filter_components()">
${self.make_grid_filter_numeric_value_component()}
${self.make_grid_filter_date_value_component()}
${self.make_grid_filter_component()}
</%def>
<%def name="make_grid_filter_numeric_value_component()">
<% request.register_component('grid-filter-numeric-value', 'GridFilterNumericValue') %>
<script type="text/x-template" id="grid-filter-numeric-value-template">
<div class="level">
<div class="level-left">
<div class="level-item">
<b-input v-model="startValue"
ref="startValue"
@input="startValueChanged">
</b-input>
</div>
<div v-show="wantsRange"
class="level-item">
and
</div>
<div v-show="wantsRange"
class="level-item">
<b-input v-model="endValue"
ref="endValue"
@input="endValueChanged">
</b-input>
</div>
</div>
</div>
</script>
<script>
const GridFilterNumericValue = {
template: '#grid-filter-numeric-value-template',
props: {
${'modelValue' if request.use_oruga else 'value'}: String,
wantsRange: Boolean,
},
data() {
const value = this.${'modelValue' if request.use_oruga else 'value'}
const {startValue, endValue} = this.parseValue(value)
return {
startValue,
endValue,
}
},
watch: {
// when changing from e.g. 'equal' to 'between' filter verbs,
// must proclaim new filter value, to reflect (lack of) range
wantsRange(val) {
if (val) {
this.$emit('input', this.startValue + '|' + this.endValue)
} else {
this.$emit('input', this.startValue)
}
},
${'modelValue' if request.use_oruga else 'value'}(to, from) {
const parsed = this.parseValue(to)
this.startValue = parsed.startValue
this.endValue = parsed.endValue
},
},
methods: {
focus() {
this.$refs.startValue.focus()
},
startValueChanged(value) {
if (this.wantsRange) {
value += '|' + this.endValue
}
this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
},
endValueChanged(value) {
value = this.startValue + '|' + value
this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
},
parseValue(value) {
let startValue = null
let endValue = null
if (this.wantsRange) {
if (value.includes('|')) {
let values = value.split('|')
if (values.length == 2) {
startValue = values[0]
endValue = values[1]
} else {
startValue = value
}
} else {
startValue = value
}
} else {
startValue = value
}
return {
startValue,
endValue,
}
},
},
}
Vue.component('grid-filter-numeric-value', GridFilterNumericValue)
</script>
</%def>
<%def name="make_grid_filter_date_value_component()">
<% request.register_component('grid-filter-date-value', 'GridFilterDateValue') %>
<script type="text/x-template" id="grid-filter-date-value-template">
<div class="level">
<div class="level-left">
<div class="level-item">
<tailbone-datepicker v-model="startDate"
ref="startDate"
@${'update:model-value' if request.use_oruga else 'input'}="startDateChanged">
</tailbone-datepicker>
</div>
<div v-show="dateRange"
class="level-item">
and
</div>
<div v-show="dateRange"
class="level-item">
<tailbone-datepicker v-model="endDate"
ref="endDate"
@${'update:model-value' if request.use_oruga else 'input'}="endDateChanged">
</tailbone-datepicker>
</div>
</div>
</div>
</script>
<script>
const GridFilterDateValue = {
template: '#grid-filter-date-value-template',
props: {
${'modelValue' if request.use_oruga else 'value'}: String,
dateRange: Boolean,
},
data() {
let startDate = null
let endDate = null
let value = this.${'modelValue' if request.use_oruga else 'value'}
if (value) {
if (this.dateRange) {
let values = value.split('|')
if (values.length == 2) {
startDate = this.parseDate(values[0])
endDate = this.parseDate(values[1])
} else { // no end date specified?
startDate = this.parseDate(value)
}
} else { // not a range, so start date only
startDate = this.parseDate(value)
}
}
return {
startDate,
endDate,
}
},
methods: {
focus() {
this.$refs.startDate.focus()
},
formatDate(date) {
if (date === null) {
return null
}
if (typeof(date) == 'string') {
return date
}
// just need to convert to simple ISO date format here, seems
// like there should be a more obvious way to do that?
var year = date.getFullYear()
var month = date.getMonth() + 1
var day = date.getDate()
month = month < 10 ? '0' + month : month
day = day < 10 ? '0' + day : day
return year + '-' + month + '-' + day
},
parseDate(value) {
if (value) {
// note, this assumes classic YYYY-MM-DD (i.e. ISO?) format
const parts = value.split('-')
return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
}
},
startDateChanged(value) {
value = this.formatDate(value)
if (this.dateRange) {
value += '|' + this.formatDate(this.endDate)
}
this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
},
endDateChanged(value) {
value = this.formatDate(this.startDate) + '|' + this.formatDate(value)
this.$emit("${'update:modelValue' if request.use_oruga else 'input'}", value)
},
},
}
Vue.component('grid-filter-date-value', GridFilterDateValue)
</script>
</%def>
<%def name="make_grid_filter_component()">
<% request.register_component('grid-filter', 'GridFilter') %>
<script type="text/x-template" id="grid-filter-template">
<div class="filter"
v-show="filter.visible"
style="display: flex; gap: 0.5rem;">
<div class="filter-fieldname">
<b-button @click="filter.active = !filter.active"
icon-pack="fas"
:icon-left="filter.active ? 'check' : null">
{{ filter.label }}
</b-button>
</div>
<div v-show="filter.active"
style="display: flex; gap: 0.5rem;">
<b-select v-model="filter.verb"
@input="focusValue()"
class="filter-verb">
<option v-for="verb in filter.verbs"
:key="verb"
:value="verb">
{{ filter.verb_labels[verb] }}
</option>
</b-select>
## only one of the following "value input" elements will be rendered
<grid-filter-date-value v-if="filter.data_type == 'date'"
v-model="filter.value"
v-show="valuedVerb()"
:date-range="filter.verb == 'between'"
ref="valueInput">
</grid-filter-date-value>
<b-select v-if="filter.data_type == 'choice'"
v-model="filter.value"
v-show="valuedVerb()"
ref="valueInput">
<option v-for="choice in filter.choices"
:key="choice"
:value="choice">
{{ filter.choice_labels[choice] || choice }}
</option>
</b-select>
<grid-filter-numeric-value v-if="filter.data_type == 'number'"
v-model="filter.value"
v-show="valuedVerb()"
:wants-range="filter.verb == 'between'"
ref="valueInput">
</grid-filter-numeric-value>
<b-input v-if="filter.data_type == 'string' && !multiValuedVerb()"
v-model="filter.value"
v-show="valuedVerb()"
ref="valueInput">
</b-input>
<b-input v-if="filter.data_type == 'string' && multiValuedVerb()"
type="textarea"
v-model="filter.value"
v-show="valuedVerb()"
ref="valueInput">
</b-input>
</div>
</div>
</script>
<script>
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)
</script>
</%def>

View file

@ -1,70 +0,0 @@
## -*- coding: utf-8; -*-
<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()">
<grid-filter v-for="key in filtersSequence"
:key="key"
:filter="filters[key]"
ref="gridFilters">
</grid-filter>
<b-field grouped>
<b-button type="is-primary"
native-type="submit"
icon-pack="fas"
icon-left="check"
class="control">
Apply Filters
</b-button>
<b-button v-if="!addFilterShow"
icon-pack="fas"
icon-left="plus"
class="control"
@click="addFilterButton">
Add Filter
</b-button>
<b-autocomplete v-if="addFilterShow"
ref="addFilterAutocomplete"
:data="addFilterChoices"
v-model="addFilterTerm"
placeholder="Add Filter"
field="key"
:custom-formatter="filtr => filtr.label"
open-on-focus
keep-first
icon-pack="fas"
clearable
clear-on-select
@select="addFilterSelect"
@keydown.native="addFilterKeydown">
</b-autocomplete>
<b-button @click="resetView()"
icon-pack="fas"
icon-left="home"
class="control">
Default View
</b-button>
<b-button @click="clearFilters()"
icon-pack="fas"
icon-left="trash"
class="control">
No Filters
</b-button>
% if allow_save_defaults and request.user:
<b-button @click="saveDefaults()"
icon-pack="fas"
icon-left="save"
class="control">
Save Defaults
</b-button>
% endif
</b-field>
</form>

View file

@ -0,0 +1,3 @@
## -*- coding: utf-8; -*-
<%inherit file="/grids/complete.mako" />
${parent.body()}

View file

@ -1,33 +1,7 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/page.mako" /> <%inherit file="wuttaweb:templates/home.mako" />
<%namespace name="base_meta" file="/base_meta.mako" />
<%def name="title()">Home</%def>
<%def name="extra_styles()">
${parent.extra_styles()}
<style type="text/css">
.logo {
text-align: center;
}
.logo img {
margin: 3em auto;
max-height: 350px;
max-width: 800px;
}
</style>
</%def>
## DEPRECATED; remains for back-compat
<%def name="render_this_page()"> <%def name="render_this_page()">
${self.page_content()} ${self.page_content()}
</%def> </%def>
<%def name="page_content()">
<div class="logo">
${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))}
<h1>Welcome to ${base_meta.app_title()}</h1>
</div>
</%def>
${parent.body()}

View file

@ -6,61 +6,65 @@
<h3 class="is-size-3">Designated Handlers</h3> <h3 class="is-size-3">Designated Handlers</h3>
<b-table :data="handlersData" <${b}-table :data="handlersData"
narrowed narrowed
icon-pack="fas" icon-pack="fas"
:default-sort="['host_title', 'asc']"> :default-sort="['host_title', 'asc']">
<b-table-column field="host_title" <${b}-table-column field="host_title"
label="Data Source" label="Data Source"
v-slot="props" v-slot="props"
sortable> sortable>
{{ props.row.host_title }} {{ props.row.host_title }}
</b-table-column> </${b}-table-column>
<b-table-column field="local_title" <${b}-table-column field="local_title"
label="Data Target" label="Data Target"
v-slot="props" v-slot="props"
sortable> sortable>
{{ props.row.local_title }} {{ props.row.local_title }}
</b-table-column> </${b}-table-column>
<b-table-column field="direction" <${b}-table-column field="direction"
label="Direction" label="Direction"
v-slot="props" v-slot="props"
sortable> sortable>
{{ props.row.direction_display }} {{ props.row.direction_display }}
</b-table-column> </${b}-table-column>
<b-table-column field="handler_spec" <${b}-table-column field="handler_spec"
label="Handler Spec" label="Handler Spec"
v-slot="props" v-slot="props"
sortable> sortable>
{{ props.row.handler_spec }} {{ props.row.handler_spec }}
</b-table-column> </${b}-table-column>
<b-table-column field="cmd" <${b}-table-column field="cmd"
label="Command" label="Command"
v-slot="props" v-slot="props"
sortable> sortable>
{{ props.row.command }} {{ props.row.subcommand }} {{ props.row.command }} {{ props.row.subcommand }}
</b-table-column> </${b}-table-column>
<b-table-column field="runas" <${b}-table-column field="runas"
label="Default Runas" label="Default Runas"
v-slot="props" v-slot="props"
sortable> sortable>
{{ props.row.default_runas }} {{ props.row.default_runas }}
</b-table-column> </${b}-table-column>
<b-table-column label="Actions" <${b}-table-column label="Actions"
v-slot="props"> v-slot="props">
<a href="#" class="grid-action" <a href="#" class="grid-action"
@click.prevent="editHandler(props.row)"> @click.prevent="editHandler(props.row)">
% if request.use_oruga:
<o-icon icon="edit" />
% else:
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
% endif
Edit Edit
</a> </a>
</b-table-column> </${b}-table-column>
<template slot="empty"> <template #empty>
<section class="section"> <section class="section">
<div class="content has-text-grey has-text-centered"> <div class="content has-text-grey has-text-centered">
<p> <p>
<b-icon <b-icon
pack="fas" pack="fas"
icon="fas fa-sad-tear" icon="sad-tear"
size="is-large"> size="is-large">
</b-icon> </b-icon>
</p> </p>
@ -68,7 +72,7 @@
</div> </div>
</section> </section>
</template> </template>
</b-table> </${b}-table>
<b-modal :active.sync="editHandlerShowDialog"> <b-modal :active.sync="editHandlerShowDialog">
<div class="card"> <div class="card">
@ -140,9 +144,9 @@
</b-modal> </b-modal>
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
ThisPageData.handlersData = ${json.dumps(handlers_data)|n} ThisPageData.handlersData = ${json.dumps(handlers_data)|n}
@ -199,6 +203,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -63,28 +63,26 @@
</div> </div>
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
${form.component_studly}Data.submittingRun = false ${form.vue_component}Data.submittingRun = false
${form.component_studly}Data.submittingExplain = false ${form.vue_component}Data.submittingExplain = false
${form.component_studly}Data.runJob = false ${form.vue_component}Data.runJob = false
${form.component_studly}.methods.submitRun = function() { ${form.vue_component}.methods.submitRun = function() {
this.submittingRun = true this.submittingRun = true
this.runJob = true this.runJob = true
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.${form.component_studly}.submit() this.$refs.${form.vue_component}.submit()
}) })
} }
${form.component_studly}.methods.submitExplain = function() { ${form.vue_component}.methods.submitExplain = function() {
this.submittingExplain = true this.submittingExplain = true
this.$refs.${form.component_studly}.submit() this.$refs.${form.vue_component}.submit()
} }
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -1,78 +1,17 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/form.mako" /> <%inherit file="wuttaweb:templates/auth/login.mako" />
<%namespace name="base_meta" file="/base_meta.mako" />
<%def name="title()">Login</%def>
## TODO: this will not be needed with wuttaform
<%def name="extra_styles()"> <%def name="extra_styles()">
${parent.extra_styles()} ${parent.extra_styles()}
<style type="text/css"> <style>
.logo img { .card-content .buttons {
display: block;
margin: 3rem auto;
max-height: 350px;
max-width: 800px;
}
/* must force a particular label with, in order to make sure */
/* the username and password inputs are the same size */
.field.is-horizontal .field-label .label {
text-align: left;
width: 6rem;
}
.buttons {
justify-content: right; justify-content: right;
} }
</style> </style>
</%def> </%def>
<%def name="logo()"> ## DEPRECATED; remains for back-compat
${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))}
</%def>
<%def name="login_form()">
<div class="form">
${form.render_deform(form_kwargs={'data-ajax': 'false'})|n}
</div>
</%def>
<%def name="render_this_page()"> <%def name="render_this_page()">
${self.page_content()} ${self.page_content()}
</%def> </%def>
<%def name="page_content()">
<div class="logo">
${self.logo()}
</div>
<div class="columns is-centered">
<div class="column is-narrow">
<div class="card">
<div class="card-content">
<tailbone-form></tailbone-form>
</div>
</div>
</div>
</div>
</%def>
<%def name="modify_this_page_vars()">
<script type="text/javascript">
TailboneForm.mounted = function() {
this.$refs.username.focus()
}
TailboneForm.methods.usernameKeydown = function(event) {
if (event.which == 13) {
event.preventDefault()
this.$refs.password.focus()
}
}
</script>
</%def>
${parent.body()}

View file

@ -22,48 +22,56 @@
</div> </div>
<div class="block" style="padding-left: 2rem; display: flex;"> <div class="block" style="padding-left: 2rem; display: flex;">
<b-table :data="overnightTasks"> <${b}-table :data="overnightTasks">
<!-- <b-table-column field="key" --> <!-- <${b}-table-column field="key" -->
<!-- label="Key" --> <!-- label="Key" -->
<!-- sortable> --> <!-- sortable> -->
<!-- {{ props.row.key }} --> <!-- {{ props.row.key }} -->
<!-- </b-table-column> --> <!-- </${b}-table-column> -->
<b-table-column field="key" <${b}-table-column field="key"
label="Key" label="Key"
v-slot="props"> v-slot="props">
{{ props.row.key }} {{ props.row.key }}
</b-table-column> </${b}-table-column>
<b-table-column field="description" <${b}-table-column field="description"
label="Description" label="Description"
v-slot="props"> v-slot="props">
{{ props.row.description }} {{ props.row.description }}
</b-table-column> </${b}-table-column>
<b-table-column field="class_name" <${b}-table-column field="class_name"
label="Class Name" label="Class Name"
v-slot="props"> v-slot="props">
{{ props.row.class_name }} {{ props.row.class_name }}
</b-table-column> </${b}-table-column>
<b-table-column field="script" <${b}-table-column field="script"
label="Script" label="Script"
v-slot="props"> v-slot="props">
{{ props.row.script }} {{ props.row.script }}
</b-table-column> </${b}-table-column>
<b-table-column label="Actions" <${b}-table-column label="Actions"
v-slot="props"> v-slot="props">
<a href="#" <a href="#"
@click.prevent="overnightTaskEdit(props.row)"> @click.prevent="overnightTaskEdit(props.row)">
<i class="fas fa-edit"></i> % if request.use_oruga:
<o-icon icon="edit" />
% else:
<i class="fas fa-edit"></i>
% endif
Edit Edit
</a> </a>
&nbsp; &nbsp;
<a href="#" <a href="#"
class="has-text-danger" class="has-text-danger"
@click.prevent="overnightTaskDelete(props.row)"> @click.prevent="overnightTaskDelete(props.row)">
<i class="fas fa-trash"></i> % if request.use_oruga:
<o-icon icon="trash" />
% else:
<i class="fas fa-trash"></i>
% endif
Delete Delete
</a> </a>
</b-table-column> </${b}-table-column>
</b-table> </${b}-table>
<b-modal has-modal-card <b-modal has-modal-card
:active.sync="overnightTaskShowDialog"> :active.sync="overnightTaskShowDialog">
@ -77,31 +85,31 @@
<b-field label="Key" <b-field label="Key"
:type="overnightTaskKey ? null : 'is-danger'"> :type="overnightTaskKey ? null : 'is-danger'">
<b-input v-model.trim="overnightTaskKey" <b-input v-model.trim="overnightTaskKey"
ref="overnightTaskKey"> ref="overnightTaskKey"
</b-input> expanded />
</b-field> </b-field>
<b-field label="Description" <b-field label="Description"
:type="overnightTaskDescription ? null : 'is-danger'"> :type="overnightTaskDescription ? null : 'is-danger'">
<b-input v-model.trim="overnightTaskDescription" <b-input v-model.trim="overnightTaskDescription"
ref="overnightTaskDescription"> ref="overnightTaskDescription"
</b-input> expanded />
</b-field> </b-field>
<b-field label="Module"> <b-field label="Module">
<b-input v-model.trim="overnightTaskModule"> <b-input v-model.trim="overnightTaskModule"
</b-input> expanded />
</b-field> </b-field>
<b-field label="Class Name"> <b-field label="Class Name">
<b-input v-model.trim="overnightTaskClass"> <b-input v-model.trim="overnightTaskClass"
</b-input> expanded />
</b-field> </b-field>
<b-field label="Script"> <b-field label="Script">
<b-input v-model.trim="overnightTaskScript"> <b-input v-model.trim="overnightTaskScript"
</b-input> expanded />
</b-field> </b-field>
<b-field label="Notes"> <b-field label="Notes">
<b-input v-model.trim="overnightTaskNotes" <b-input v-model.trim="overnightTaskNotes"
type="textarea"> type="textarea"
</b-input> expanded />
</b-field> </b-field>
</section> </section>
@ -139,48 +147,56 @@
</div> </div>
<div class="block" style="padding-left: 2rem; display: flex;"> <div class="block" style="padding-left: 2rem; display: flex;">
<b-table :data="backfillTasks"> <${b}-table :data="backfillTasks">
<b-table-column field="key" <${b}-table-column field="key"
label="Key" label="Key"
v-slot="props"> v-slot="props">
{{ props.row.key }} {{ props.row.key }}
</b-table-column> </${b}-table-column>
<b-table-column field="description" <${b}-table-column field="description"
label="Description" label="Description"
v-slot="props"> v-slot="props">
{{ props.row.description }} {{ props.row.description }}
</b-table-column> </${b}-table-column>
<b-table-column field="script" <${b}-table-column field="script"
label="Script" label="Script"
v-slot="props"> v-slot="props">
{{ props.row.script }} {{ props.row.script }}
</b-table-column> </${b}-table-column>
<b-table-column field="forward" <${b}-table-column field="forward"
label="Orientation" label="Orientation"
v-slot="props"> v-slot="props">
{{ props.row.forward ? "Forward" : "Backward" }} {{ props.row.forward ? "Forward" : "Backward" }}
</b-table-column> </${b}-table-column>
<b-table-column field="target_date" <${b}-table-column field="target_date"
label="Target Date" label="Target Date"
v-slot="props"> v-slot="props">
{{ props.row.target_date }} {{ props.row.target_date }}
</b-table-column> </${b}-table-column>
<b-table-column label="Actions" <${b}-table-column label="Actions"
v-slot="props"> v-slot="props">
<a href="#" <a href="#"
@click.prevent="backfillTaskEdit(props.row)"> @click.prevent="backfillTaskEdit(props.row)">
<i class="fas fa-edit"></i> % if request.use_oruga:
<o-icon icon="edit" />
% else:
<i class="fas fa-edit"></i>
% endif
Edit Edit
</a> </a>
&nbsp; &nbsp;
<a href="#" <a href="#"
class="has-text-danger" class="has-text-danger"
@click.prevent="backfillTaskDelete(props.row)"> @click.prevent="backfillTaskDelete(props.row)">
<i class="fas fa-trash"></i> % if request.use_oruga:
<o-icon icon="trash" />
% else:
<i class="fas fa-trash"></i>
% endif
Delete Delete
</a> </a>
</b-table-column> </${b}-table-column>
</b-table> </${b}-table>
<b-modal has-modal-card <b-modal has-modal-card
:active.sync="backfillTaskShowDialog"> :active.sync="backfillTaskShowDialog">
@ -194,19 +210,19 @@
<b-field label="Key" <b-field label="Key"
:type="backfillTaskKey ? null : 'is-danger'"> :type="backfillTaskKey ? null : 'is-danger'">
<b-input v-model.trim="backfillTaskKey" <b-input v-model.trim="backfillTaskKey"
ref="backfillTaskKey"> ref="backfillTaskKey"
</b-input> expanded />
</b-field> </b-field>
<b-field label="Description" <b-field label="Description"
:type="backfillTaskDescription ? null : 'is-danger'"> :type="backfillTaskDescription ? null : 'is-danger'">
<b-input v-model.trim="backfillTaskDescription" <b-input v-model.trim="backfillTaskDescription"
ref="backfillTaskDescription"> ref="backfillTaskDescription"
</b-input> expanded />
</b-field> </b-field>
<b-field label="Script" <b-field label="Script"
:type="backfillTaskScript ? null : 'is-danger'"> :type="backfillTaskScript ? null : 'is-danger'">
<b-input v-model.trim="backfillTaskScript"> <b-input v-model.trim="backfillTaskScript"
</b-input> expanded />
</b-field> </b-field>
<b-field grouped> <b-field grouped>
<b-field label="Orientation"> <b-field label="Orientation">
@ -222,8 +238,8 @@
</b-field> </b-field>
<b-field label="Notes"> <b-field label="Notes">
<b-input v-model.trim="backfillTaskNotes" <b-input v-model.trim="backfillTaskNotes"
type="textarea"> type="textarea"
</b-input> expanded />
</b-field> </b-field>
</section> </section>
@ -252,7 +268,8 @@
expanded> expanded>
<b-input name="rattail.luigi.url" <b-input name="rattail.luigi.url"
v-model="simpleSettings['rattail.luigi.url']" v-model="simpleSettings['rattail.luigi.url']"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true"
expanded>
</b-input> </b-input>
</b-field> </b-field>
@ -261,7 +278,8 @@
expanded> expanded>
<b-input name="rattail.luigi.scheduler.supervisor_process_name" <b-input name="rattail.luigi.scheduler.supervisor_process_name"
v-model="simpleSettings['rattail.luigi.scheduler.supervisor_process_name']" v-model="simpleSettings['rattail.luigi.scheduler.supervisor_process_name']"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true"
expanded>
</b-input> </b-input>
</b-field> </b-field>
@ -270,7 +288,8 @@
expanded> expanded>
<b-input name="rattail.luigi.scheduler.restart_command" <b-input name="rattail.luigi.scheduler.restart_command"
v-model="simpleSettings['rattail.luigi.scheduler.restart_command']" v-model="simpleSettings['rattail.luigi.scheduler.restart_command']"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true"
expanded>
</b-input> </b-input>
</b-field> </b-field>
@ -278,9 +297,9 @@
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n}
ThisPageData.overnightTaskShowDialog = false ThisPageData.overnightTaskShowDialog = false
@ -406,6 +425,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -53,25 +53,25 @@
<h3 class="block is-size-3">Overnight Tasks</h3> <h3 class="block is-size-3">Overnight Tasks</h3>
<b-table :data="overnightTasks" hoverable> <${b}-table :data="overnightTasks" hoverable>
<b-table-column field="description" <${b}-table-column field="description"
label="Description" label="Description"
v-slot="props"> v-slot="props">
{{ props.row.description }} {{ props.row.description }}
</b-table-column> </${b}-table-column>
<b-table-column field="script" <${b}-table-column field="script"
label="Command" label="Command"
v-slot="props"> v-slot="props">
{{ props.row.script || props.row.class_name }} {{ props.row.script || props.row.class_name }}
</b-table-column> </${b}-table-column>
<b-table-column field="last_date" <${b}-table-column field="last_date"
label="Last Date" label="Last Date"
v-slot="props"> v-slot="props">
<span :class="overnightTextClass(props.row)"> <span :class="overnightTextClass(props.row)">
{{ props.row.last_date || "never!" }} {{ props.row.last_date || "never!" }}
</span> </span>
</b-table-column> </${b}-table-column>
<b-table-column label="Actions" <${b}-table-column label="Actions"
v-slot="props"> v-slot="props">
<b-button type="is-primary" <b-button type="is-primary"
icon-pack="fas" icon-pack="fas"
@ -79,8 +79,13 @@
@click="overnightTaskLaunchInit(props.row)"> @click="overnightTaskLaunchInit(props.row)">
Launch Launch
</b-button> </b-button>
<b-modal has-modal-card <${b}-modal has-modal-card
:active.sync="overnightTaskShowLaunchDialog"> % if request.use_oruga:
v-model:active="overnightTaskShowLaunchDialog"
% else:
:active.sync="overnightTaskShowLaunchDialog"
% endif
>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
@ -127,12 +132,12 @@
</b-button> </b-button>
</footer> </footer>
</div> </div>
</b-modal> </${b}-modal>
</b-table-column> </${b}-table-column>
<template #empty> <template #empty>
<p class="block">No tasks defined.</p> <p class="block">No tasks defined.</p>
</template> </template>
</b-table> </${b}-table>
% endif % endif
@ -140,35 +145,35 @@
<h3 class="block is-size-3">Backfill Tasks</h3> <h3 class="block is-size-3">Backfill Tasks</h3>
<b-table :data="backfillTasks" hoverable> <${b}-table :data="backfillTasks" hoverable>
<b-table-column field="description" <${b}-table-column field="description"
label="Description" label="Description"
v-slot="props"> v-slot="props">
{{ props.row.description }} {{ props.row.description }}
</b-table-column> </${b}-table-column>
<b-table-column field="script" <${b}-table-column field="script"
label="Script" label="Script"
v-slot="props"> v-slot="props">
{{ props.row.script }} {{ props.row.script }}
</b-table-column> </${b}-table-column>
<b-table-column field="forward" <${b}-table-column field="forward"
label="Orientation" label="Orientation"
v-slot="props"> v-slot="props">
{{ props.row.forward ? "Forward" : "Backward" }} {{ props.row.forward ? "Forward" : "Backward" }}
</b-table-column> </${b}-table-column>
<b-table-column field="last_date" <${b}-table-column field="last_date"
label="Last Date" label="Last Date"
v-slot="props"> v-slot="props">
<span :class="backfillTextClass(props.row)"> <span :class="backfillTextClass(props.row)">
{{ props.row.last_date }} {{ props.row.last_date }}
</span> </span>
</b-table-column> </${b}-table-column>
<b-table-column field="target_date" <${b}-table-column field="target_date"
label="Target Date" label="Target Date"
v-slot="props"> v-slot="props">
{{ props.row.target_date }} {{ props.row.target_date }}
</b-table-column> </${b}-table-column>
<b-table-column label="Actions" <${b}-table-column label="Actions"
v-slot="props"> v-slot="props">
<b-button type="is-primary" <b-button type="is-primary"
icon-pack="fas" icon-pack="fas"
@ -176,14 +181,19 @@
@click="backfillTaskLaunch(props.row)"> @click="backfillTaskLaunch(props.row)">
Launch Launch
</b-button> </b-button>
</b-table-column> </${b}-table-column>
<template #empty> <template #empty>
<p class="block">No tasks defined.</p> <p class="block">No tasks defined.</p>
</template> </template>
</b-table> </${b}-table>
<b-modal has-modal-card <${b}-modal has-modal-card
:active.sync="backfillTaskShowLaunchDialog"> % if request.use_oruga:
v-model:active="backfillTaskShowLaunchDialog"
% else:
:active.sync="backfillTaskShowLaunchDialog"
% endif
>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
@ -238,16 +248,16 @@
</b-button> </b-button>
</footer> </footer>
</div> </div>
</b-modal> </${b}-modal>
% endif % endif
</div> </div>
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
% if master.has_perm('restart_scheduler'): % if master.has_perm('restart_scheduler'):
@ -364,6 +374,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -34,9 +34,9 @@
${h.end_form()} ${h.end_form()}
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
TailboneFormData.formSubmitting = false TailboneFormData.formSubmitting = false
TailboneFormData.submitButtonText = "Yes, please clone away" TailboneFormData.submitButtonText = "Yes, please clone away"
@ -48,6 +48,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -1,6 +1,6 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/form.mako" /> <%inherit file="/form.mako" />
<%def name="title()">New ${model_title_plural if master.creates_multiple else model_title}</%def> <%def name="title()">New ${model_title_plural if getattr(master, 'creates_multiple', False) else model_title}</%def>
${parent.body()} ${parent.body()}

View file

@ -27,26 +27,21 @@
<b-button type="is-primary is-danger" <b-button type="is-primary is-danger"
native-type="submit" native-type="submit"
:disabled="formSubmitting"> :disabled="formSubmitting">
{{ formButtonText }} {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
</b-button> </b-button>
</div> </div>
${h.end_form()} ${h.end_form()}
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
TailboneFormData.formSubmitting = false ${form.vue_component}Data.formSubmitting = false
TailboneFormData.formButtonText = "Yes, please DELETE this data forever!"
TailboneForm.methods.submitForm = function() { ${form.vue_component}.methods.submitForm = function() {
this.formSubmitting = true this.formSubmitting = true
this.formButtonText = "Working, please wait..."
} }
</script> </script>
</%def> </%def>
${parent.body()}

View file

@ -1,10 +1,18 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/form.mako" /> <%inherit file="/form.mako" />
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
% if master.deletable and instance_deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': <script>
<script type="text/javascript">
## declare extra data needed by form
% if form is not Undefined and getattr(form, 'json_data', None):
% for key, value in form.json_data.items():
${form.vue_component}Data.${key} = ${json.dumps(value)|n}
% endfor
% endif
% if master.deletable and instance_deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
ThisPage.methods.deleteObject = function() { ThisPage.methods.deleteObject = function() {
if (confirm("Are you sure you wish to delete this ${model_title}?")) { if (confirm("Are you sure you wish to delete this ${model_title}?")) {
@ -12,9 +20,11 @@
} }
} }
</script> % endif
</script>
% if form is not Undefined and hasattr(form, 'render_included_templates'):
${form.render_included_templates()}
% endif % endif
</%def> </%def>
${parent.body()}

View file

@ -12,187 +12,178 @@
<%def name="content_title()"></%def> <%def name="content_title()"></%def>
<%def name="context_menu_items()">
% if master.results_downloadable_csv and request.has_perm('{}.results_csv'.format(permission_prefix)):
<li>${h.link_to("Download results as CSV", url('{}.results_csv'.format(route_prefix)))}</li>
% endif
% if master.results_downloadable_xlsx and request.has_perm('{}.results_xlsx'.format(permission_prefix)):
<li>${h.link_to("Download results as XLSX", url('{}.results_xlsx'.format(route_prefix)))}</li>
% endif
% if master.has_input_file_templates and master.has_perm('create'):
% for template in six.itervalues(input_file_templates):
<li>${h.link_to("Download {} Template".format(template['label']), template['effective_url'])}</li>
% endfor
% endif
</%def>
<%def name="grid_tools()"> <%def name="grid_tools()">
## grid totals ## grid totals
% if master.supports_grid_totals: % if getattr(master, 'supports_grid_totals', False):
<b-button v-if="gridTotalsDisplay == null" <div style="display: flex; align-items: center;">
:disabled="gridTotalsFetching" <b-button v-if="gridTotalsDisplay == null"
@click="gridTotalsFetch()"> :disabled="gridTotalsFetching"
{{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} @click="gridTotalsFetch()">
</b-button> {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }}
<div v-if="gridTotalsDisplay != null" </b-button>
class="control"> <div v-if="gridTotalsDisplay != null"
Totals: {{ gridTotalsDisplay }} class="control">
Totals: {{ gridTotalsDisplay }}
</div>
</div> </div>
% endif % endif
## download search results ## download search results
% if master.results_downloadable and master.has_perm('download_results'): % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
<b-button type="is-primary" <div>
icon-pack="fas" <b-button type="is-primary"
icon-left="fas fa-download" icon-pack="fas"
@click="showDownloadResultsDialog = true" icon-left="download"
:disabled="!total"> @click="showDownloadResultsDialog = true"
Download Results :disabled="!total">
</b-button> Download Results
</b-button>
${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')}
${h.csrf_token(request)} ${h.csrf_token(request)}
<input type="hidden" name="fmt" :value="downloadResultsFormat" /> <input type="hidden" name="fmt" :value="downloadResultsFormat" />
<input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" />
${h.end_form()} ${h.end_form()}
<b-modal :active.sync="showDownloadResultsDialog"> <b-modal :active.sync="showDownloadResultsDialog">
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<p> <p>
There are There are
<span class="is-size-4 has-text-weight-bold"> <span class="is-size-4 has-text-weight-bold">
{{ total.toLocaleString('en') }} ${model_title_plural} {{ total.toLocaleString('en') }} ${model_title_plural}
</span> </span>
matching your current filters. matching your current filters.
</p> </p>
<p> <p>
You may download this set as a single data file if you like. You may download this set as a single data file if you like.
</p> </p>
<br /> <br />
<b-notification type="is-warning" :closable="false" <b-notification type="is-warning" :closable="false"
v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> v-if="downloadResultsFormat == 'xlsx' && total >= 1000">
Excel downloads for large data sets can take a long time to Excel downloads for large data sets can take a long time to
generate, and bog down the server in the meantime. You are generate, and bog down the server in the meantime. You are
encouraged to choose CSV for a large data set, even though encouraged to choose CSV for a large data set, even though
the end result (file size) may be larger with CSV. the end result (file size) may be larger with CSV.
</b-notification> </b-notification>
<div style="display: flex; justify-content: space-between"> <div style="display: flex; justify-content: space-between">
<div> <div>
<b-field horizontal label="Format"> <b-field label="Format">
<b-select v-model="downloadResultsFormat"> <b-select v-model="downloadResultsFormat">
% for key, label in six.iteritems(master.download_results_supported_formats()): % for key, label in master.download_results_supported_formats().items():
<option value="${key}">${label}</option> <option value="${key}">${label}</option>
% endfor % endfor
</b-select> </b-select>
</b-field> </b-field>
</div>
<div>
<div v-show="downloadResultsFieldsMode != 'choose'"
class="has-text-right">
<p v-if="downloadResultsFieldsMode == 'default'">
Will use DEFAULT fields.
</p>
<p v-if="downloadResultsFieldsMode == 'all'">
Will use ALL fields.
</p>
<br />
</div> </div>
<div class="buttons is-right"> <div>
<b-button type="is-primary"
v-show="downloadResultsFieldsMode != 'default'"
@click="downloadResultsUseDefaultFields()">
Use Default Fields
</b-button>
<b-button type="is-primary"
v-show="downloadResultsFieldsMode != 'all'"
@click="downloadResultsUseAllFields()">
Use All Fields
</b-button>
<b-button type="is-primary"
v-show="downloadResultsFieldsMode != 'choose'"
@click="downloadResultsFieldsMode = 'choose'">
Choose Fields
</b-button>
</div>
<div v-show="downloadResultsFieldsMode == 'choose'"> <div v-show="downloadResultsFieldsMode != 'choose'"
<div style="display: flex;"> class="has-text-right">
<div> <p v-if="downloadResultsFieldsMode == 'default'">
<b-field label="Excluded Fields"> Will use DEFAULT fields.
<b-select multiple native-size="8" </p>
expanded <p v-if="downloadResultsFieldsMode == 'all'">
ref="downloadResultsExcludedFields"> Will use ALL fields.
<option v-for="field in downloadResultsFieldsAvailable" </p>
v-if="!downloadResultsFieldsIncluded.includes(field)" <br />
:key="field" </div>
:value="field">
{{ field }} <div class="buttons is-right">
</option> <b-button type="is-primary"
</b-select> v-show="downloadResultsFieldsMode != 'default'"
</b-field> @click="downloadResultsUseDefaultFields()">
</div> Use Default Fields
<div> </b-button>
<br /><br /> <b-button type="is-primary"
<b-button style="margin: 0.5rem;" v-show="downloadResultsFieldsMode != 'all'"
@click="downloadResultsExcludeFields()"> @click="downloadResultsUseAllFields()">
&lt; Use All Fields
</b-button> </b-button>
<br /> <b-button type="is-primary"
<b-button style="margin: 0.5rem;" v-show="downloadResultsFieldsMode != 'choose'"
@click="downloadResultsIncludeFields()"> @click="downloadResultsFieldsMode = 'choose'">
&gt; Choose Fields
</b-button> </b-button>
</div> </div>
<div>
<b-field label="Included Fields"> <div v-show="downloadResultsFieldsMode == 'choose'">
<b-select multiple native-size="8" <div style="display: flex;">
expanded <div>
ref="downloadResultsIncludedFields"> <b-field label="Excluded Fields">
<option v-for="field in downloadResultsFieldsIncluded" <b-select multiple native-size="8"
:key="field" expanded
:value="field"> v-model="downloadResultsExcludedFieldsSelected"
{{ field }} ref="downloadResultsExcludedFields">
</option> <option v-for="field in downloadResultsFieldsExcluded"
</b-select> :key="field"
</b-field> :value="field">
{{ field }}
</option>
</b-select>
</b-field>
</div>
<div>
<br /><br />
<b-button style="margin: 0.5rem;"
@click="downloadResultsExcludeFields()">
&lt;
</b-button>
<br />
<b-button style="margin: 0.5rem;"
@click="downloadResultsIncludeFields()">
&gt;
</b-button>
</div>
<div>
<b-field label="Included Fields">
<b-select multiple native-size="8"
expanded
v-model="downloadResultsIncludedFieldsSelected"
ref="downloadResultsIncludedFields">
<option v-for="field in downloadResultsFieldsIncluded"
:key="field"
:value="field">
{{ field }}
</option>
</b-select>
</b-field>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div> <!-- card-content -->
</div> <!-- card-content -->
<footer class="modal-card-foot"> <footer class="modal-card-foot">
<b-button @click="showDownloadResultsDialog = false"> <b-button @click="showDownloadResultsDialog = false">
Cancel Cancel
</b-button> </b-button>
<once-button type="is-primary" <once-button type="is-primary"
@click="downloadResultsSubmit()" @click="downloadResultsSubmit()"
icon-pack="fas" icon-pack="fas"
icon-left="fas fa-download" icon-left="download"
:disabled="!downloadResultsFieldsIncluded.length" :disabled="!downloadResultsFieldsIncluded.length"
text="Download Results"> text="Download Results">
</once-button> </once-button>
</footer> </footer>
</div> </div>
</b-modal> </b-modal>
</div>
% endif % endif
## download rows for search results ## download rows for search results
% if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
<b-button type="is-primary" <b-button type="is-primary"
icon-pack="fas" icon-pack="fas"
icon-left="fas fa-download" icon-left="download"
@click="downloadResultsRows()" @click="downloadResultsRows()"
:disabled="downloadResultsRowsButtonDisabled"> :disabled="downloadResultsRowsButtonDisabled">
{{ downloadResultsRowsButtonText }} {{ downloadResultsRowsButtonText }}
@ -203,7 +194,7 @@
% endif % endif
## merge 2 objects ## merge 2 objects
% if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)):
${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})}
${h.csrf_token(request)} ${h.csrf_token(request)}
@ -221,7 +212,7 @@
% endif % endif
## enable / disable selected objects ## enable / disable selected objects
% if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')}
${h.csrf_token(request)} ${h.csrf_token(request)}
@ -243,7 +234,7 @@
% endif % endif
## delete selected objects ## delete selected objects
% if master.set_deletable and master.has_perm('delete_set'): % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')}
${h.csrf_token(request)} ${h.csrf_token(request)}
${h.hidden('uuids', v_model='selected_uuids')} ${h.hidden('uuids', v_model='selected_uuids')}
@ -258,7 +249,7 @@
% endif % endif
## delete search results ## delete search results
% if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')}
${h.csrf_token(request)} ${h.csrf_token(request)}
<b-button type="is-danger" <b-button type="is-danger"
@ -274,6 +265,11 @@
</%def> </%def>
## DEPRECATED; remains for back-compat
<%def name="render_this_page()">
${self.page_content()}
</%def>
<%def name="page_content()"> <%def name="page_content()">
% if download_results_path: % if download_results_path:
@ -292,7 +288,7 @@
${self.render_grid_component()} ${self.render_grid_component()}
% if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
${h.form('#', ref='deleteObjectForm')} ${h.form('#', ref='deleteObjectForm')}
${h.csrf_token(request)} ${h.csrf_token(request)}
${h.end_form()} ${h.end_form()}
@ -300,45 +296,34 @@
</%def> </%def>
<%def name="render_grid_component()"> <%def name="render_grid_component()">
<${grid.component} ref="grid" :csrftoken="csrftoken" ${grid.render_vue_tag()}
% if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple':
@deleteActionClicked="deleteObject"
% endif
>
</${grid.component}>
</%def> </%def>
<%def name="make_this_page_component()"> ##############################
${parent.make_this_page_component()} ## vue components
##############################
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
## DEPRECATED; called for back-compat
${self.make_grid_component()}
</%def>
## DEPRECATED; remains for back-compat
<%def name="make_grid_component()">
${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script type="text/javascript"> <script type="text/javascript">
${grid.component_studly}.data = function() { return ${grid.component_studly}Data } % if getattr(master, 'supports_grid_totals', False):
${grid.vue_component}Data.gridTotalsDisplay = null
${grid.vue_component}Data.gridTotalsFetching = false
Vue.component('${grid.component}', ${grid.component_studly}) ${grid.vue_component}.methods.gridTotalsFetch = function() {
</script>
</%def>
<%def name="render_this_page()">
${self.page_content()}
</%def>
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
## TODO: stop using |n filter
${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n}
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
<script type="text/javascript">
% if master.supports_grid_totals:
${grid.component_studly}Data.gridTotalsDisplay = null
${grid.component_studly}Data.gridTotalsFetching = false
${grid.component_studly}.methods.gridTotalsFetch = function() {
this.gridTotalsFetching = true this.gridTotalsFetching = true
let url = '${url(f'{route_prefix}.fetch_grid_totals')}' let url = '${url(f'{route_prefix}.fetch_grid_totals')}'
@ -350,7 +335,7 @@
}) })
} }
${grid.component_studly}.methods.appliedFiltersHook = function() { ${grid.vue_component}.methods.appliedFiltersHook = function() {
this.gridTotalsDisplay = null this.gridTotalsDisplay = null
this.gridTotalsFetching = false this.gridTotalsFetching = false
} }
@ -394,7 +379,7 @@
% endif % endif
## delete single object ## delete single object
% if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple': % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple':
ThisPage.methods.deleteObject = function(url) { ThisPage.methods.deleteObject = function(url) {
if (confirm("Are you sure you wish to delete this ${model_title}?")) { if (confirm("Are you sure you wish to delete this ${model_title}?")) {
let form = this.$refs.deleteObjectForm let form = this.$refs.deleteObjectForm
@ -405,16 +390,19 @@
% endif % endif
## download results ## download results
% if master.results_downloadable and master.has_perm('download_results'): % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
${grid.component_studly}Data.downloadResultsFormat = '${master.download_results_default_format()}' ${grid.vue_component}Data.downloadResultsFormat = '${master.download_results_default_format()}'
${grid.component_studly}Data.showDownloadResultsDialog = false ${grid.vue_component}Data.showDownloadResultsDialog = false
${grid.component_studly}Data.downloadResultsFieldsMode = 'default' ${grid.vue_component}Data.downloadResultsFieldsMode = 'default'
${grid.component_studly}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n} ${grid.vue_component}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n}
${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} ${grid.vue_component}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n}
${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} ${grid.vue_component}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n}
${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() { ${grid.vue_component}Data.downloadResultsExcludedFieldsSelected = []
${grid.vue_component}Data.downloadResultsIncludedFieldsSelected = []
${grid.vue_component}.computed.downloadResultsFieldsExcluded = function() {
let excluded = [] let excluded = []
this.downloadResultsFieldsAvailable.forEach(field => { this.downloadResultsFieldsAvailable.forEach(field => {
if (!this.downloadResultsFieldsIncluded.includes(field)) { if (!this.downloadResultsFieldsIncluded.includes(field)) {
@ -424,70 +412,73 @@
return excluded return excluded
} }
${grid.component_studly}.methods.downloadResultsExcludeFields = function() { ${grid.vue_component}.methods.downloadResultsExcludeFields = function() {
let selected = this.$refs.downloadResultsIncludedFields.selected const selected = Array.from(this.downloadResultsIncludedFieldsSelected)
if (!selected) { if (!selected) {
return return
} }
selected = Array.from(selected)
selected.forEach(field => {
// de-select the entry within "included" field input selected.forEach(field => {
let index = this.$refs.downloadResultsIncludedFields.selected.indexOf(field) let index
if (index > -1) {
this.$refs.downloadResultsIncludedFields.selected.splice(index, 1) // remove field from selected
index = this.downloadResultsIncludedFieldsSelected.indexOf(field)
if (index >= 0) {
this.downloadResultsIncludedFieldsSelected.splice(index, 1)
} }
// remove field from official "included" list // remove field from included
// nb. excluded list will reflect this change too
index = this.downloadResultsFieldsIncluded.indexOf(field) index = this.downloadResultsFieldsIncluded.indexOf(field)
if (index > -1) { if (index >= 0) {
this.downloadResultsFieldsIncluded.splice(index, 1) this.downloadResultsFieldsIncluded.splice(index, 1)
} }
}, this) })
} }
${grid.component_studly}.methods.downloadResultsIncludeFields = function() { ${grid.vue_component}.methods.downloadResultsIncludeFields = function() {
let selected = this.$refs.downloadResultsExcludedFields.selected const selected = Array.from(this.downloadResultsExcludedFieldsSelected)
if (!selected) { if (!selected) {
return return
} }
selected = Array.from(selected)
selected.forEach(field => {
// de-select the entry within "excluded" field input selected.forEach(field => {
let index = this.$refs.downloadResultsExcludedFields.selected.indexOf(field) let index
if (index > -1) {
this.$refs.downloadResultsExcludedFields.selected.splice(index, 1) // remove field from selected
index = this.downloadResultsExcludedFieldsSelected.indexOf(field)
if (index >= 0) {
this.downloadResultsExcludedFieldsSelected.splice(index, 1)
} }
// add field to official "included" list // add field to included
// nb. excluded list will reflect this change too
this.downloadResultsFieldsIncluded.push(field) this.downloadResultsFieldsIncluded.push(field)
})
}, this)
} }
${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() { ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() {
this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault) this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault)
this.downloadResultsFieldsMode = 'default' this.downloadResultsFieldsMode = 'default'
} }
${grid.component_studly}.methods.downloadResultsUseAllFields = function() { ${grid.vue_component}.methods.downloadResultsUseAllFields = function() {
this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable) this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable)
this.downloadResultsFieldsMode = 'all' this.downloadResultsFieldsMode = 'all'
} }
${grid.component_studly}.methods.downloadResultsSubmit = function() { ${grid.vue_component}.methods.downloadResultsSubmit = function() {
this.$refs.download_results_form.submit() this.$refs.download_results_form.submit()
} }
% endif % endif
## download rows for results ## download rows for results
% if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
${grid.component_studly}Data.downloadResultsRowsButtonDisabled = false ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false
${grid.component_studly}Data.downloadResultsRowsButtonText = "Download Rows for Results" ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results"
${grid.component_studly}.methods.downloadResultsRows = function() { ${grid.vue_component}.methods.downloadResultsRows = function() {
if (confirm("This will generate an Excel file which contains " if (confirm("This will generate an Excel file which contains "
+ "not the results themselves, but the *rows* for " + "not the results themselves, but the *rows* for "
+ "each.\n\nAre you sure you want this?")) { + "each.\n\nAre you sure you want this?")) {
@ -499,12 +490,12 @@
% endif % endif
## enable / disable selected objects ## enable / disable selected objects
% if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
${grid.component_studly}Data.enableSelectedSubmitting = false ${grid.vue_component}Data.enableSelectedSubmitting = false
${grid.component_studly}Data.enableSelectedText = "Enable Selected" ${grid.vue_component}Data.enableSelectedText = "Enable Selected"
${grid.component_studly}.computed.enableSelectedDisabled = function() { ${grid.vue_component}.computed.enableSelectedDisabled = function() {
if (this.enableSelectedSubmitting) { if (this.enableSelectedSubmitting) {
return true return true
} }
@ -514,7 +505,7 @@
return false return false
} }
${grid.component_studly}.methods.enableSelectedSubmit = function() { ${grid.vue_component}.methods.enableSelectedSubmit = function() {
let uuids = this.checkedRowUUIDs() let uuids = this.checkedRowUUIDs()
if (!uuids.length) { if (!uuids.length) {
alert("You must first select one or more objects to disable.") alert("You must first select one or more objects to disable.")
@ -529,10 +520,10 @@
this.$refs.enable_selected_form.submit() this.$refs.enable_selected_form.submit()
} }
${grid.component_studly}Data.disableSelectedSubmitting = false ${grid.vue_component}Data.disableSelectedSubmitting = false
${grid.component_studly}Data.disableSelectedText = "Disable Selected" ${grid.vue_component}Data.disableSelectedText = "Disable Selected"
${grid.component_studly}.computed.disableSelectedDisabled = function() { ${grid.vue_component}.computed.disableSelectedDisabled = function() {
if (this.disableSelectedSubmitting) { if (this.disableSelectedSubmitting) {
return true return true
} }
@ -542,7 +533,7 @@
return false return false
} }
${grid.component_studly}.methods.disableSelectedSubmit = function() { ${grid.vue_component}.methods.disableSelectedSubmit = function() {
let uuids = this.checkedRowUUIDs() let uuids = this.checkedRowUUIDs()
if (!uuids.length) { if (!uuids.length) {
alert("You must first select one or more objects to disable.") alert("You must first select one or more objects to disable.")
@ -560,12 +551,12 @@
% endif % endif
## delete selected objects ## delete selected objects
% if master.set_deletable and master.has_perm('delete_set'): % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
${grid.component_studly}Data.deleteSelectedSubmitting = false ${grid.vue_component}Data.deleteSelectedSubmitting = false
${grid.component_studly}Data.deleteSelectedText = "Delete Selected" ${grid.vue_component}Data.deleteSelectedText = "Delete Selected"
${grid.component_studly}.computed.deleteSelectedDisabled = function() { ${grid.vue_component}.computed.deleteSelectedDisabled = function() {
if (this.deleteSelectedSubmitting) { if (this.deleteSelectedSubmitting) {
return true return true
} }
@ -575,7 +566,7 @@
return false return false
} }
${grid.component_studly}.methods.deleteSelectedSubmit = function() { ${grid.vue_component}.methods.deleteSelectedSubmit = function() {
let uuids = this.checkedRowUUIDs() let uuids = this.checkedRowUUIDs()
if (!uuids.length) { if (!uuids.length) {
alert("You must first select one or more objects to disable.") alert("You must first select one or more objects to disable.")
@ -591,12 +582,12 @@
} }
% endif % endif
% if master.bulk_deletable and master.has_perm('bulk_delete'): % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'):
${grid.component_studly}Data.deleteResultsSubmitting = false ${grid.vue_component}Data.deleteResultsSubmitting = false
${grid.component_studly}Data.deleteResultsText = "Delete Results" ${grid.vue_component}Data.deleteResultsText = "Delete Results"
${grid.component_studly}.computed.deleteResultsDisabled = function() { ${grid.vue_component}.computed.deleteResultsDisabled = function() {
if (this.deleteResultsSubmitting) { if (this.deleteResultsSubmitting) {
return true return true
} }
@ -606,7 +597,7 @@
return false return false
} }
${grid.component_studly}.methods.deleteResultsSubmit = function() { ${grid.vue_component}.methods.deleteResultsSubmit = function() {
// TODO: show "plural model title" here? // TODO: show "plural model title" here?
if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) {
return return
@ -619,12 +610,12 @@
% endif % endif
% if master.mergeable and master.has_perm('merge'): % if getattr(master, 'mergeable', False) and master.has_perm('merge'):
${grid.component_studly}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" ${grid.vue_component}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}"
${grid.component_studly}Data.mergeFormSubmitting = false ${grid.vue_component}Data.mergeFormSubmitting = false
${grid.component_studly}.methods.submitMergeForm = function() { ${grid.vue_component}.methods.submitMergeForm = function() {
this.mergeFormSubmitting = true this.mergeFormSubmitting = true
this.mergeFormButtonText = "Working, please wait..." this.mergeFormButtonText = "Working, please wait..."
} }
@ -632,5 +623,10 @@
</script> </script>
</%def> </%def>
<%def name="make_vue_components()">
${parent.body()} ${parent.make_vue_components()}
<script>
${grid.vue_component}.data = function() { return ${grid.vue_component}Data }
Vue.component('${grid.vue_tagname}', ${grid.vue_component})
</script>
</%def>

View file

@ -109,8 +109,8 @@
<merge-buttons></merge-buttons> <merge-buttons></merge-buttons>
</%def> </%def>
<%def name="render_this_page_template()"> <%def name="render_vue_templates()">
${parent.render_this_page_template()} ${parent.render_vue_templates()}
<script type="text/x-template" id="merge-buttons-template"> <script type="text/x-template" id="merge-buttons-template">
<div class="level" style="margin-top: 2em;"> <div class="level" style="margin-top: 2em;">
@ -147,11 +147,7 @@
</div> </div>
</div> </div>
</script> </script>
</%def> <script>
<%def name="make_this_page_component()">
${parent.make_this_page_component()}
<script type="text/javascript">
const MergeButtons = { const MergeButtons = {
template: '#merge-buttons-template', template: '#merge-buttons-template',
@ -175,10 +171,13 @@
} }
} }
Vue.component('merge-buttons', MergeButtons)
</script> </script>
</%def> </%def>
<%def name="make_vue_components()">
${parent.body()} ${parent.make_vue_components()}
<script>
Vue.component('merge-buttons', MergeButtons)
<% request.register_component('merge-buttons', 'MergeButtons') %>
</script>
</%def>

View file

@ -16,27 +16,16 @@
${self.page_content()} ${self.page_content()}
</%def> </%def>
<%def name="make_this_page_component()">
${parent.make_this_page_component()}
<script type="text/javascript">
TailboneGrid.data = function() { return TailboneGridData }
Vue.component('tailbone-grid', TailboneGrid)
</script>
</%def>
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
## TODO: stop using |n filter
${grid.render_complete()|n}
</%def>
<%def name="page_content()"> <%def name="page_content()">
<tailbone-grid :csrftoken="csrftoken"> ${grid.render_vue_tag(**{':csrftoken': 'csrftoken'})}
</tailbone-grid>
</%def> </%def>
${parent.body()} <%def name="render_vue_templates()">
${parent.render_vue_templates()}
${grid.render_vue_template()}
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
${grid.render_vue_finalize()}
</%def>

View file

@ -8,12 +8,15 @@
</%def> </%def>
<%def name="render_instance_header_title_extras()"> <%def name="render_instance_header_title_extras()">
% if master.touchable and master.has_perm('touch'): % if getattr(master, 'touchable', False) and master.has_perm('touch'):
<b-button title="&quot;Touch&quot; this record to trigger sync" <b-button title="&quot;Touch&quot; this record to trigger sync"
icon-pack="fas"
icon-left="hand-pointer"
@click="touchRecord()" @click="touchRecord()"
:disabled="touchSubmitting"> :disabled="touchSubmitting">
% if request.use_oruga:
<o-icon icon="hand-pointer" />
% else:
<span><i class="fa fa-hand-pointer"></i></span>
% endif
</b-button> </b-button>
% endif % endif
% if expose_versions: % if expose_versions:
@ -34,7 +37,7 @@
% if xref_buttons or xref_links: % if xref_buttons or xref_links:
<nav class="panel"> <nav class="panel">
<p class="panel-heading">Cross-Reference</p> <p class="panel-heading">Cross-Reference</p>
<div class="panel-block buttons"> <div class="panel-block">
<div style="display: flex; flex-direction: column; gap: 0.5rem;"> <div style="display: flex; flex-direction: column; gap: 0.5rem;">
% for button in xref_buttons: % for button in xref_buttons:
${button} ${button}
@ -48,12 +51,6 @@
% endif % endif
</%def> </%def>
<%def name="context_menu_items()">
## TODO: either make this configurable, or just lose it.
## nobody seems to ever find it useful in practice.
## <li>${h.link_to("Permalink for this {}".format(model_title), action_url('view', instance))}</li>
</%def>
<%def name="render_row_grid_tools()"> <%def name="render_row_grid_tools()">
${rows_grid_tools} ${rows_grid_tools}
% if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'): % if master.rows_downloadable_xlsx and master.has_perm('row_results_xlsx'):
@ -96,7 +93,7 @@
${parent.render_this_page()} ${parent.render_this_page()}
## render row grid ## render row grid
% if master.has_rows: % if getattr(master, 'has_rows', False):
<br /> <br />
% if rows_title: % if rows_title:
<h4 class="block is-size-4">${rows_title}</h4> <h4 class="block is-size-4">${rows_title}</h4>
@ -113,17 +110,25 @@
<p class="block"> <p class="block">
<a href="${master.get_action_url('versions', instance)}" <a href="${master.get_action_url('versions', instance)}"
target="_blank"> target="_blank">
<i class="fas fa-external-link-alt"></i> % if request.use_oruga:
<o-icon icon="external-link-alt" />
% else:
<i class="fas fa-external-link-alt"></i>
% endif
View as separate page View as separate page
</a> </a>
</p> </p>
</div> </div>
<versions-grid ref="versionsGrid" ${versions_grid.render_vue_tag(ref='versionsGrid', **{'@view-revision': 'viewRevision'})}
@view-revision="viewRevision">
</versions-grid>
<b-modal :active.sync="viewVersionShowDialog" :width="1200"> <${b}-modal :width="1200"
% if request.use_oruga:
v-model:active="viewVersionShowDialog"
% else:
:active.sync="viewVersionShowDialog"
% endif
>
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div style="display: flex; flex-direction: column; gap: 1.5rem;"> <div style="display: flex; flex-direction: column; gap: 1.5rem;">
@ -170,7 +175,11 @@
<div> <div>
<a :href="viewVersionData.url" <a :href="viewVersionData.url"
target="_blank"> target="_blank">
<i class="fas fa-external-link-alt"></i> % if request.use_oruga:
<o-icon icon="external-link-alt" />
% else:
<i class="fas fa-external-link-alt"></i>
% endif
View as separate page View as separate page
</a> </a>
</div> </div>
@ -187,6 +196,7 @@
<p class="block has-text-weight-bold"> <p class="block has-text-weight-bold">
{{ version.model_title }} {{ version.model_title }}
({{ version.operation }})
</p> </p>
<table class="diff monospace is-size-7" <table class="diff monospace is-size-7"
@ -213,34 +223,50 @@
</div> </div>
</div> </div>
<b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading> % if request.use_oruga:
<o-loading v-model:active="viewVersionLoading" :is-full-page="false" />
% else:
<b-loading :active.sync="viewVersionLoading" :is-full-page="false"></b-loading>
% endif
</div> </div>
</div> </div>
</b-modal> </${b}-modal>
</div> </div>
% endif % endif
</%def> </%def>
<%def name="render_row_grid_component()"> <%def name="render_row_grid_component()">
<tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid> ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')}
</%def> </%def>
<%def name="render_this_page_template()"> <%def name="render_vue_templates()">
% if master.has_rows: ${parent.render_vue_templates()}
## TODO: stop using |n filter % if getattr(master, 'has_rows', False):
${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))}
% endif % endif
${parent.render_this_page_template()}
% if expose_versions: % if expose_versions:
${versions_grid.render_complete()|n} ${versions_grid.render_vue_template()}
% endif % endif
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
% if expose_versions: <script>
<script type="text/javascript">
% if getattr(master, 'touchable', False) and master.has_perm('touch'):
WholePageData.touchSubmitting = false
WholePage.methods.touchRecord = function() {
this.touchSubmitting = true
location.href = '${master.get_action_url('touch', instance)}'
}
% endif
% if expose_versions:
WholePageData.viewingHistory = false
ThisPage.props.viewingHistory = Boolean ThisPage.props.viewingHistory = Boolean
ThisPageData.gettingRevisions = false ThisPageData.gettingRevisions = false
@ -295,48 +321,16 @@
this.viewVersionShowAllFields = !this.viewVersionShowAllFields this.viewVersionShowAllFields = !this.viewVersionShowAllFields
} }
</script> % endif
</script>
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
% if getattr(master, 'has_rows', False):
${rows_grid.render_vue_finalize()}
% endif
% if expose_versions:
${versions_grid.render_vue_finalize()}
% endif % endif
</%def> </%def>
<%def name="modify_whole_page_vars()">
${parent.modify_whole_page_vars()}
<script type="text/javascript">
% if master.touchable and master.has_perm('touch'):
WholePageData.touchSubmitting = false
WholePage.methods.touchRecord = function() {
this.touchSubmitting = true
location.href = '${master.get_action_url('touch', instance)}'
}
% endif
% if expose_versions:
WholePageData.viewingHistory = false
% endif
</script>
</%def>
<%def name="finalize_this_page_vars()">
${parent.finalize_this_page_vars()}
<script type="text/javascript">
% if master.has_rows:
TailboneGrid.data = function() { return TailboneGridData }
Vue.component('tailbone-grid', TailboneGrid)
% endif
% if expose_versions:
VersionsGrid.data = function() { return VersionsGridData }
Vue.component('versions-grid', VersionsGrid)
% endif
</script>
</%def>
${parent.body()}

View file

@ -19,48 +19,39 @@
</%def> </%def>
<%def name="page_content()"> <%def name="page_content()">
## TODO: this was basically copied from Revel diff template..need to abstract
<div class="form-wrapper"> <div class="form-wrapper" style="margin: 1rem; 0;">
<div class="form">
<div class="form"> <b-field label="Changed" horizontal>
<span>${h.pretty_datetime(request.rattail_config, changed)}</span>
</b-field>
<b-field label="Changed by" horizontal>
<span>${transaction.user or ''}</span>
</b-field>
<b-field label="IP Address" horizontal>
<span>${transaction.remote_addr}</span>
</b-field>
<b-field label="Comment" horizontal>
<span>${transaction.meta.get('comment') or ''}</span>
</b-field>
<b-field label="TXN ID" horizontal>
<span>${transaction.id}</span>
</b-field>
<div class="field-wrapper">
<label>Changed</label>
<div class="field">${h.pretty_datetime(request.rattail_config, changed)}</div>
</div> </div>
<div class="field-wrapper">
<label>Changed by</label>
<div class="field">${transaction.user or ''}</div>
</div>
<div class="field-wrapper">
<label>IP Address</label>
<div class="field">${transaction.remote_addr}</div>
</div>
<div class="field-wrapper">
<label>Comment</label>
<div class="field">${transaction.meta.get('comment') or ''}</div>
</div>
<div class="field-wrapper">
<label>TXN ID</label>
<div class="field">${transaction.id}</div>
</div>
</div> </div>
</div><!-- form-wrapper --> <div class="versions-wrapper">
% for diff in version_diffs:
<div class="versions-wrapper"> <h4 class="is-size-4 block">${diff.title}</h4>
% for diff in version_diffs: ${diff.render_html()}
<h4 class="is-size-4 block">${diff.title}</h4> % endfor
${diff.render_html()} </div>
% endfor
</div>
</%def> </%def>

View file

@ -52,9 +52,9 @@
</div> </div>
</%def> </%def>
<%def name="modify_this_page_vars()"> <%def name="modify_vue_vars()">
${parent.modify_this_page_vars()} ${parent.modify_vue_vars()}
<script type="text/javascript"> <script>
ThisPage.methods.getLabelForKey = function(key) { ThisPage.methods.getLabelForKey = function(key) {
switch (key) { switch (key) {
@ -75,6 +75,3 @@
</script> </script>
</%def> </%def>
${parent.body()}

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