Compare commits

...

79 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
61 changed files with 2119 additions and 1677 deletions

View file

@ -5,6 +5,170 @@ 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/) 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). 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) ## v0.20.0 (2024-08-20)
### Feat ### Feat

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

@ -27,10 +27,10 @@ templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 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://rattailproject.org/docs/wuttaweb/', None), 'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), 'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
} }
# allow todo entries to show up # allow todo entries to show up

View file

@ -6,9 +6,9 @@ build-backend = "hatchling.build"
[project] [project]
name = "Tailbone" name = "Tailbone"
version = "0.20.0" version = "0.22.7"
description = "Backoffice Web Application for Rattail" description = "Backoffice Web Application for Rattail"
readme = "README.rst" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
license = {text = "GNU GPL v3+"} license = {text = "GNU GPL v3+"}
classifiers = [ classifiers = [
@ -53,13 +53,13 @@ dependencies = [
"pyramid_mako", "pyramid_mako",
"pyramid_retry", "pyramid_retry",
"pyramid_tm", "pyramid_tm",
"rattail[db,bouncer]>=0.18.1", "rattail[db,bouncer]>=0.20.1",
"sa-filters", "sa-filters",
"simplejson", "simplejson",
"transaction", "transaction",
"waitress", "waitress",
"WebHelpers2", "WebHelpers2",
"WuttaWeb>=0.11.0", "WuttaWeb>=0.21.0",
"zope.sqlalchemy>=1.5", "zope.sqlalchemy>=1.5",
] ]
@ -84,9 +84,9 @@ tailbone = "tailbone.config:ConfigExtension"
[project.urls] [project.urls]
Homepage = "https://rattailproject.org" Homepage = "https://rattailproject.org"
Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone" Repository = "https://forgejo.wuttaproject.org/rattail/tailbone"
Issues = "https://redmine.rattailproject.org/projects/tailbone/issues" Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues"
Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md" Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md"
[tool.commitizen] [tool.commitizen]

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

@ -26,7 +26,6 @@ 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
@ -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

View file

@ -62,6 +62,17 @@ def make_rattail_config(settings):
# nb. this is for compaibility with wuttaweb # nb. this is for compaibility with wuttaweb
settings['wutta_config'] = rattail_config 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, 'appdb_engine'): if hasattr(rattail_config, 'appdb_engine'):
tailbone.db.Session.configure(bind=rattail_config.appdb_engine) tailbone.db.Session.configure(bind=rattail_config.appdb_engine)

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

@ -401,6 +401,8 @@ class Form(object):
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)
@ -1037,9 +1039,9 @@ class Form(object):
def render_vue_tag(self, **kwargs): def render_vue_tag(self, **kwargs):
""" """ """ """
return self.render_vuejs_component() return self.render_vuejs_component(**kwargs)
def render_vuejs_component(self): def render_vuejs_component(self, **kwargs):
""" """
Render the Vue.js component HTML for the form. Render the Vue.js component HTML for the form.
@ -1050,10 +1052,11 @@ 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.vue_tagname, **kwargs) return HTML.tag(self.vue_tagname, **kw)
def set_json_data(self, key, value): def set_json_data(self, key, value):
""" """
@ -1380,7 +1383,11 @@ class Form(object):
return getattr(record, field_name) return getattr(record, field_name)
except AttributeError: except AttributeError:
pass pass
return record[field_name]
try:
return record[field_name]
except TypeError:
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:

View file

@ -24,9 +24,10 @@
Core Grid Classes Core Grid Classes
""" """
from urllib.parse import urlencode import inspect
import warnings
import logging import logging
import warnings
from urllib.parse import urlencode
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
@ -196,11 +197,7 @@ class Grid(WuttaGrid):
raw_renderers={}, raw_renderers={},
extra_row_class=None, extra_row_class=None,
url='#', url='#',
joiners={},
filterable=False,
filters={},
use_byte_string_filters=False, use_byte_string_filters=False,
searchable={},
checkboxes=False, checkboxes=False,
checked=None, checked=None,
check_handler=None, check_handler=None,
@ -238,7 +235,7 @@ class Grid(WuttaGrid):
if 'pageable' in kwargs: if 'pageable' in kwargs:
warnings.warn("pageable param is deprecated for Grid(); " warnings.warn("pageable param is deprecated for Grid(); "
"please use vue_tagname param instead", "please use paginated param instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
kwargs.setdefault('paginated', kwargs.pop('pageable')) kwargs.setdefault('paginated', kwargs.pop('pageable'))
@ -254,10 +251,21 @@ class Grid(WuttaGrid):
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
kwargs.setdefault('page', kwargs.pop('default_page')) kwargs.setdefault('page', kwargs.pop('default_page'))
if 'searchable' in kwargs:
warnings.warn("searchable param is deprecated for Grid(); "
"please use searchable_columns param instead",
DeprecationWarning, stacklevel=2)
kwargs.setdefault('searchable_columns', kwargs.pop('searchable'))
# TODO: this should not be needed once all templates correctly # TODO: this should not be needed once all templates correctly
# reference grid.vue_component etc. # reference grid.vue_component etc.
kwargs.setdefault('vue_tagname', 'tailbone-grid') kwargs.setdefault('vue_tagname', 'tailbone-grid')
# nb. these must be set before super init, as they are
# referenced when constructing filters
self.assume_local_times = assume_local_times
self.use_byte_string_filters = use_byte_string_filters
kwargs['key'] = key kwargs['key'] = key
kwargs['data'] = data kwargs['data'] = data
super().__init__(request, **kwargs) super().__init__(request, **kwargs)
@ -275,19 +283,11 @@ class Grid(WuttaGrid):
self.width = width self.width = width
self.enums = enums or {} self.enums = enums or {}
self.assume_local_times = assume_local_times
self.renderers = self.make_default_renderers(self.renderers) self.renderers = self.make_default_renderers(self.renderers)
self.raw_renderers = raw_renderers or {} self.raw_renderers = raw_renderers or {}
self.invisible = invisible or [] self.invisible = invisible or []
self.extra_row_class = extra_row_class self.extra_row_class = extra_row_class
self.url = url self.url = url
self.joiners = joiners or {}
self.filterable = filterable
self.use_byte_string_filters = use_byte_string_filters
self.filters = self.make_filters(filters)
self.searchable = searchable or {}
self.checkboxes = checkboxes self.checkboxes = checkboxes
self.checked = checked self.checked = checked
@ -443,10 +443,14 @@ class Grid(WuttaGrid):
self.remove(oldfield) self.remove(oldfield)
def set_joiner(self, key, joiner): def set_joiner(self, key, joiner):
""" """
if joiner is None: if joiner is None:
self.joiners.pop(key, None) warnings.warn("specifying None is deprecated for Grid.set_joiner(); "
"please use Grid.remove_joiner() instead",
DeprecationWarning, stacklevel=2)
self.remove_joiner(key)
else: else:
self.joiners[key] = joiner super().set_joiner(key, joiner)
def set_sorter(self, key, *args, **kwargs): def set_sorter(self, key, *args, **kwargs):
""" """ """ """
@ -474,41 +478,19 @@ class Grid(WuttaGrid):
self.sorters[key] = self.make_sorter(*args, **kwargs) self.sorters[key] = self.make_sorter(*args, **kwargs)
def set_filter(self, key, *args, **kwargs): def set_filter(self, key, *args, **kwargs):
if len(args) == 1 and args[0] is None: """ """
self.remove_filter(key) if len(args) == 1:
else: if args[0] is None:
if 'label' not in kwargs and key in self.labels: warnings.warn("specifying None is deprecated for Grid.set_filter(); "
kwargs['label'] = self.labels[key] "please use Grid.remove_filter() instead",
self.filters[key] = self.make_filter(key, *args, **kwargs) DeprecationWarning, stacklevel=2)
self.remove_filter(key)
return
def set_searchable(self, key, searchable=True): # TODO: our make_filter() signature differs from upstream,
if searchable: # so must call it explicitly instead of delegating to super
self.searchable[key] = True kwargs.setdefault('label', self.get_label(key))
else: self.filters[key] = self.make_filter(key, *args, **kwargs)
self.searchable.pop(key, None)
def is_searchable(self, key):
return self.searchable.get(key, False)
def remove_filter(self, key):
self.filters.pop(key, None)
def set_label(self, key, label, column_only=False):
"""
Set/override the label for a column.
This overrides
:meth:`~wuttaweb:wuttaweb.grids.base.Grid.set_label()` to add
the following params:
:param column_only: Boolean indicating whether the label
should be applied *only* to the column header (if
``True``), vs. applying also to the filter (if ``False``).
"""
super().set_label(key, label)
if not column_only and key in self.filters:
self.filters[key].label = label
def set_click_handler(self, key, handler): def set_click_handler(self, key, handler):
if handler: if handler:
@ -593,7 +575,11 @@ class Grid(WuttaGrid):
return getattr(obj, column_name) return getattr(obj, column_name)
except AttributeError: except AttributeError:
pass pass
return obj[column_name]
try:
return obj[column_name]
except TypeError:
pass
def render_currency(self, obj, column_name): def render_currency(self, obj, column_name):
value = self.obtain_value(obj, column_name) value = self.obtain_value(obj, column_name)
@ -708,6 +694,14 @@ class Grid(WuttaGrid):
def actions_column_format(self, column_number, row_number, item): def actions_column_format(self, column_number, row_number, item):
return HTML.td(self.render_actions(item, row_number), class_='actions') return HTML.td(self.render_actions(item, row_number), class_='actions')
# TODO: upstream should handle this..
def make_backend_filters(self, filters=None):
""" """
final = self.get_default_filters()
if filters:
final.update(filters)
return final
def get_default_filters(self): def get_default_filters(self):
""" """
Returns the default set of filters provided by the grid. Returns the default set of filters provided by the grid.
@ -732,16 +726,6 @@ class Grid(WuttaGrid):
filters[prop.key] = self.make_filter(prop.key, column) filters[prop.key] = self.make_filter(prop.key, column)
return filters return filters
def make_filters(self, filters=None):
"""
Returns an initial set of filters which will be available to the grid.
The grid itself may or may not provide some default filters, and the
``filters`` kwarg may contain additions and/or overrides.
"""
if filters:
return filters
return self.get_default_filters()
def make_filter(self, key, column, **kwargs): def make_filter(self, key, column, **kwargs):
""" """
Make a filter suitable for use with the given column. Make a filter suitable for use with the given column.
@ -879,9 +863,13 @@ class Grid(WuttaGrid):
settings['page'] = self.page settings['page'] = self.page
if self.filterable: if self.filterable:
for filtr in self.iter_filters(): for filtr in self.iter_filters():
settings['filter.{}.active'.format(filtr.key)] = filtr.default_active defaults = self.filter_defaults.get(filtr.key, {})
settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb settings[f'filter.{filtr.key}.active'] = defaults.get('active',
settings['filter.{}.value'.format(filtr.key)] = filtr.default_value filtr.default_active)
settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
filtr.default_verb)
settings[f'filter.{filtr.key}.value'] = defaults.get('value',
filtr.default_value)
# If user has default settings on file, apply those first. # If user has default settings on file, apply those first.
if self.user_has_defaults(): if self.user_has_defaults():
@ -889,13 +877,13 @@ class Grid(WuttaGrid):
# If request contains instruction to reset to default filters, then we # If request contains instruction to reset to default filters, then we
# can skip the rest of the request/session checks. # can skip the rest of the request/session checks.
if self.request.GET.get('reset-to-default-filters') == 'true': if self.request.GET.get('reset-view'):
pass pass
# If request has filter settings, grab those, then grab sort/pager # If request has filter settings, grab those, then grab sort/pager
# settings from request or session. # settings from request or session.
elif self.filterable and self.request_has_settings('filter'): elif self.request_has_settings('filter'):
self.update_filter_settings(settings, 'request') self.update_filter_settings(settings, src='request')
if self.request_has_settings('sort'): if self.request_has_settings('sort'):
self.update_sort_settings(settings, src='request') self.update_sort_settings(settings, src='request')
else: else:
@ -907,7 +895,7 @@ class Grid(WuttaGrid):
# settings from request or session. # settings from request or session.
elif self.request_has_settings('sort'): elif self.request_has_settings('sort'):
self.update_sort_settings(settings, src='request') self.update_sort_settings(settings, src='request')
self.update_filter_settings(settings, 'session') self.update_filter_settings(settings, src='session')
self.update_page_settings(settings) self.update_page_settings(settings)
# NOTE: These next two are functionally equivalent, but are kept # NOTE: These next two are functionally equivalent, but are kept
@ -917,12 +905,12 @@ class Grid(WuttaGrid):
# grab those, then grab filter/sort settings from session. # grab those, then grab filter/sort settings from session.
elif self.request_has_settings('page'): elif self.request_has_settings('page'):
self.update_page_settings(settings) self.update_page_settings(settings)
self.update_filter_settings(settings, 'session') self.update_filter_settings(settings, src='session')
self.update_sort_settings(settings, src='session') self.update_sort_settings(settings, src='session')
# If request has no settings, grab all from session. # If request has no settings, grab all from session.
elif self.session_has_settings(): elif self.session_has_settings():
self.update_filter_settings(settings, 'session') self.update_filter_settings(settings, src='session')
self.update_sort_settings(settings, src='session') self.update_sort_settings(settings, src='session')
self.update_page_settings(settings) self.update_page_settings(settings)
@ -1062,18 +1050,11 @@ class Grid(WuttaGrid):
merge('page', int) merge('page', int)
def request_has_settings(self, type_): def request_has_settings(self, type_):
""" """ """
Determine if the current request (GET query string) contains any if super().request_has_settings(type_):
filter/sort settings for the grid. return True
"""
if type_ == 'filter':
for filtr in self.iter_filters():
if filtr.key in self.request.GET:
return True
if 'filter' in self.request.GET: # user may be applying empty filters
return True
elif type_ == 'sort': if type_ == 'sort':
# TODO: remove this eventually, but some links in the wild # TODO: remove this eventually, but some links in the wild
# may still include these params, so leave it for now # may still include these params, so leave it for now
@ -1081,14 +1062,6 @@ class Grid(WuttaGrid):
if key in self.request.GET: if key in self.request.GET:
return True return True
if 'sort1key' in self.request.GET:
return True
elif type_ == 'page':
for key in ['pagesize', 'page']:
if key in self.request.GET:
return True
return False return False
def session_has_settings(self): def session_has_settings(self):
@ -1104,72 +1077,6 @@ class Grid(WuttaGrid):
return any([key.startswith(f'{prefix}.filter') return any([key.startswith(f'{prefix}.filter')
for key in self.request.session]) for key in self.request.session])
def update_filter_settings(self, settings, source):
"""
Updates a settings dictionary according to filter settings data found
in either the GET query string, or session storage.
:param settings: Dictionary of initial settings, which is to be updated.
:param source: String identifying the source to consult for settings
data. Must be one of: ``('request', 'session')``.
"""
if not self.filterable:
return
for filtr in self.iter_filters():
prefix = 'filter.{}'.format(filtr.key)
if source == 'request':
# consider filter active if query string contains a value for it
settings['{}.active'.format(prefix)] = filtr.key in self.request.GET
settings['{}.verb'.format(prefix)] = self.get_setting(
settings, f'{filtr.key}.verb', src='request', default='')
settings['{}.value'.format(prefix)] = self.get_setting(
settings, filtr.key, src='request', default='')
else: # source = session
settings['{}.active'.format(prefix)] = self.get_setting(
settings, f'{prefix}.active', src='session',
normalize=lambda v: str(v).lower() == 'true', default=False)
settings['{}.verb'.format(prefix)] = self.get_setting(
settings, f'{prefix}.verb', src='session', default='')
settings['{}.value'.format(prefix)] = self.get_setting(
settings, f'{prefix}.value', src='session', default='')
def update_page_settings(self, settings):
"""
Updates a settings dictionary according to pager settings data found in
either the GET query string, or session storage.
Note that due to how the actual pager functions, the effective settings
will often come from *both* the request and session. This is so that
e.g. the page size will remain constant (coming from the session) while
the user jumps between pages (which only provides the single setting).
:param settings: Dictionary of initial settings, which is to be updated.
"""
if not self.paginated:
return
pagesize = self.request.GET.get('pagesize')
if pagesize is not None:
if pagesize.isdigit():
settings['pagesize'] = int(pagesize)
else:
pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key))
if pagesize is not None:
settings['pagesize'] = pagesize
page = self.request.GET.get('page')
if page is not None:
if page.isdigit():
settings['page'] = int(page)
else:
page = self.request.session.get('grid.{}.page'.format(self.key))
if page is not None:
settings['page'] = int(page)
def persist_settings(self, settings, dest='session'): def persist_settings(self, settings, dest='session'):
""" """ """ """
if dest not in ('defaults', 'session'): if dest not in ('defaults', 'session'):
@ -1257,89 +1164,12 @@ class Grid(WuttaGrid):
return data return data
def sort_data(self, data, sorters=None):
""" """
if sorters is None:
sorters = self.active_sorters
if not sorters:
return data
# nb. when data is a query, we want to apply sorters in the
# requested order, so the final query has order_by() in the
# correct "as-is" sequence. however when data is a list we
# must do the opposite, applying in the reverse order, so the
# final list has the most "important" sort(s) applied last.
if not isinstance(data, orm.Query):
sorters = reversed(sorters)
for sorter in sorters:
sortkey = sorter['key']
sortdir = sorter['dir']
# cannot sort unless we have a sorter callable
sortfunc = self.sorters.get(sortkey)
if not sortfunc:
return data
# join appropriate model if needed
if sortkey in self.joiners and sortkey not in self.joined:
data = self.joiners[sortkey](data)
self.joined.add(sortkey)
# invoke the sorter
data = sortfunc(data, sortdir)
return data
def paginate_data(self, data):
"""
Paginate the given data set according to current settings, and return
the result.
"""
# we of course assume our current page is correct, at first
pager = self.make_pager(data)
# if pager has detected that our current page is outside the valid
# range, we must re-orient ourself around the "new" (valid) page
if pager.page != self.page:
self.page = pager.page
self.request.session['grid.{}.page'.format(self.key)] = self.page
pager = self.make_pager(data)
return pager
def make_pager(self, data):
# TODO: this seems hacky..normally we expect `data` to be a
# query of course, but in some cases it may be a list instead.
# if so then we can't use ORM pager
if isinstance(data, list):
import paginate
return paginate.Page(data,
items_per_page=self.pagesize,
page=self.page)
return SqlalchemyOrmPage(data,
items_per_page=self.pagesize,
page=self.page,
url_maker=URLMaker(self.request))
def make_visible_data(self): def make_visible_data(self):
""" """ """
Apply various settings to the raw data set, to produce a final data warnings.warn("grid.make_visible_data() method is deprecated; "
set. This will page / sort / filter as necessary, according to the "please use grid.get_visible_data() instead",
grid's defaults and the current request etc. DeprecationWarning, stacklevel=2)
""" return self.get_visible_data()
self.joined = set()
data = self.data
if self.filterable:
data = self.filter_data(data)
if self.sortable:
data = self.sort_data(data)
if self.paginated:
self.pager = self.paginate_data(data)
data = self.pager
return data
def render_vue_tag(self, master=None, **kwargs): def render_vue_tag(self, master=None, **kwargs):
""" """ """ """
@ -1362,7 +1192,7 @@ class Grid(WuttaGrid):
includes the context menu items and grid tools. includes the context menu items and grid tools.
""" """
if 'grid_columns' not in kwargs: if 'grid_columns' not in kwargs:
kwargs['grid_columns'] = self.get_table_columns() kwargs['grid_columns'] = self.get_vue_columns()
if 'grid_data' not in kwargs: if 'grid_data' not in kwargs:
kwargs['grid_data'] = self.get_table_data() kwargs['grid_data'] = self.get_table_data()
@ -1385,6 +1215,7 @@ class Grid(WuttaGrid):
return HTML.literal(html) return HTML.literal(html)
def render_buefy(self, **kwargs): def render_buefy(self, **kwargs):
""" """
warnings.warn("Grid.render_buefy() is deprecated; " warnings.warn("Grid.render_buefy() is deprecated; "
"please use Grid.render_complete() instead", "please use Grid.render_complete() instead",
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
@ -1392,6 +1223,7 @@ class Grid(WuttaGrid):
def render_table_element(self, template='/grids/b-table.mako', def render_table_element(self, template='/grids/b-table.mako',
data_prop='gridData', empty_labels=False, data_prop='gridData', empty_labels=False,
literal=False,
**kwargs): **kwargs):
""" """
This is intended for ad-hoc "small" grids with static data. Renders This is intended for ad-hoc "small" grids with static data. Renders
@ -1403,12 +1235,15 @@ class Grid(WuttaGrid):
context['data_prop'] = data_prop context['data_prop'] = data_prop
context['empty_labels'] = empty_labels context['empty_labels'] = empty_labels
if 'grid_columns' not in context: if 'grid_columns' not in context:
context['grid_columns'] = self.get_table_columns() context['grid_columns'] = self.get_vue_columns()
context.setdefault('paginated', False) context.setdefault('paginated', False)
if context['paginated']: if context['paginated']:
context.setdefault('per_page', 20) context.setdefault('per_page', 20)
context['view_click_handler'] = self.get_view_click_handler() context['view_click_handler'] = self.get_view_click_handler()
return render(template, context) result = render(template, context)
if literal:
result = HTML.literal(result)
return result
def get_view_click_handler(self): def get_view_click_handler(self):
""" """ """ """
@ -1417,7 +1252,7 @@ class Grid(WuttaGrid):
view = None view = None
for action in self.actions: for action in self.actions:
if action.key == 'view': if action.key == 'view':
return action.click_handler return getattr(action, 'click_handler', None)
def set_filters_sequence(self, filters, only=False): def set_filters_sequence(self, filters, only=False):
""" """
@ -1491,28 +1326,6 @@ class Grid(WuttaGrid):
return data return data
def render_filters(self, template='/grids/filters.mako', **kwargs):
"""
Render the filters to a Unicode string, using the specified template.
Additional kwargs are passed along as context to the template.
"""
# Provide default data to filters form, so renderer can do some of the
# work for us.
data = {}
for filtr in self.iter_active_filters():
data['{}.active'.format(filtr.key)] = filtr.active
data['{}.verb'.format(filtr.key)] = filtr.verb
data[filtr.key] = filtr.value
form = gridfilters.GridFiltersForm(self.filters,
request=self.request,
defaults=data)
kwargs['request'] = self.request
kwargs['grid'] = self
kwargs['form'] = form
return render(template, kwargs)
def render_actions(self, row, i): # pragma: no cover def render_actions(self, row, i): # pragma: no cover
""" """ """ """
warnings.warn("grid.render_actions() is deprecated!", warnings.warn("grid.render_actions() is deprecated!",
@ -1574,22 +1387,19 @@ class Grid(WuttaGrid):
def get_vue_columns(self): def get_vue_columns(self):
""" """ """ """
return self.get_table_columns() columns = super().get_vue_columns()
for column in columns:
column['visible'] = column['field'] not in self.invisible
return columns
def get_table_columns(self): def get_table_columns(self):
""" """ """
Return a list of dicts representing all grid columns. Meant warnings.warn("grid.get_table_columns() method is deprecated; "
for use with the client-side JS table. "please use grid.get_vue_columns() instead",
""" DeprecationWarning, stacklevel=2)
columns = [] return self.get_vue_columns()
for name in self.columns:
columns.append({
'field': name,
'label': self.get_label(name),
'sortable': self.is_sortable(name),
'visible': name not in self.invisible,
})
return columns
def get_uuid_for_row(self, rowobj): def get_uuid_for_row(self, rowobj):
@ -1601,6 +1411,10 @@ class Grid(WuttaGrid):
if hasattr(rowobj, 'uuid'): if hasattr(rowobj, 'uuid'):
return rowobj.uuid return rowobj.uuid
def get_vue_context(self):
""" """
return self.get_table_data()
def get_vue_data(self): def get_vue_data(self):
""" """ """ """
table_data = self.get_table_data() table_data = self.get_table_data()
@ -1615,7 +1429,7 @@ class Grid(WuttaGrid):
return self._table_data return self._table_data
# filter / sort / paginate to get "visible" data # filter / sort / paginate to get "visible" data
raw_data = self.make_visible_data() raw_data = self.get_visible_data()
data = [] data = []
status_map = {} status_map = {}
checked = [] checked = []
@ -1656,10 +1470,22 @@ class Grid(WuttaGrid):
# leverage configured rendering logic where applicable; # leverage configured rendering logic where applicable;
# otherwise use "raw" data value as string # otherwise use "raw" data value as string
value = self.obtain_value(rowobj, name)
if self.renderers and name in self.renderers: if self.renderers and name in self.renderers:
value = self.renderers[name](rowobj, name) renderer = self.renderers[name]
else:
value = self.obtain_value(rowobj, name) # TODO: legacy renderer callables require 2 args,
# but wuttaweb callables require 3 args
sig = inspect.signature(renderer)
required = [param for param in sig.parameters.values()
if param.default == param.empty]
if len(required) == 2:
# TODO: legacy renderer
value = renderer(rowobj, name)
else: # the future
value = renderer(rowobj, name, value)
if value is None: if value is None:
value = "" value = ""
@ -1692,6 +1518,8 @@ class Grid(WuttaGrid):
results = { results = {
'data': data, 'data': data,
'row_classes': status_map,
# TODO: deprecate / remove this
'row_status_map': status_map, 'row_status_map': status_map,
} }
@ -1720,6 +1548,11 @@ class Grid(WuttaGrid):
self._table_data = results self._table_data = results
return self._table_data return self._table_data
# TODO: remove this when we use upstream GridAction
def add_action(self, key, **kwargs):
""" """
self.actions.append(GridAction(self.request, key, **kwargs))
def set_action_urls(self, row, rowobj, i): def set_action_urls(self, row, rowobj, i):
""" """
Pre-generate all action URLs for the given data row. Meant for use Pre-generate all action URLs for the given data row. Meant for use

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,12 +36,7 @@ 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 wuttaweb.util import get_liburl
from tailbone.util import (csrf_token, get_csrf_token,
pretty_datetime, raw_datetime,
render_markdown, render_markdown,
route_exists) route_exists)

View file

@ -394,6 +394,11 @@ class TailboneMenuHandler(WuttaMenuHandler):
'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',
@ -451,6 +456,11 @@ class TailboneMenuHandler(WuttaMenuHandler):
'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",
@ -703,7 +713,7 @@ class TailboneMenuHandler(WuttaMenuHandler):
}, },
{'type': 'sep'}, {'type': 'sep'},
{ {
'title': "App Details", 'title': "App Info",
'route': 'appinfo', 'route': 'appinfo',
'perm': 'appinfo.list', 'perm': 'appinfo.list',
}, },

View file

@ -1,247 +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)">
% if request.use_oruga:
<o-icon icon="edit" />
% else:
<i class="fas fa-edit"></i>
% endif
Edit
</a>
</${b}-table-column>
</${b}-table>
% for weblib in weblibs:
${h.hidden('wuttaweb.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.libver.{}']".format(weblib['key'])})}
${h.hidden('wuttaweb.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.liburl.{}']".format(weblib['key'])})}
% endfor
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="editWebLibraryShowDialog"
% else:
:active.sync="editWebLibraryShowDialog"
% endif
>
<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"
expanded />
</b-field>
<b-field label="Effective URL (as of last page load)">
<b-input v-model="editWebLibraryRecord.live_url"
disabled
expanded />
</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_vue_vars()">
${parent.modify_vue_vars()}
<script>
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[`wuttaweb.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion
this.simpleSettings[`wuttaweb.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL
this.settingsNeedSaved = true
this.editWebLibraryShowDialog = false
}
</script>
</%def>

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="page_content()"> <%def name="page_content()">
<div class="buttons"> <div class="buttons">
<once-button type="is-primary" <once-button type="is-primary"
@ -28,95 +27,5 @@
</div> </div>
<${b}-collapse class="panel" open> ${parent.page_content()}
<template #trigger="props">
<div class="panel-heading"
style="cursor: pointer;"
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</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"
style="cursor: pointer;"
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%;">
${grid.render_vue_tag()}
</div>
</div>
</${b}-collapse>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n}
</script>
</%def> </%def>

View file

@ -632,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')}
@ -642,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>
@ -668,7 +686,7 @@
text="Edit This"> text="Edit This">
</once-button> </once-button>
% endif % endif
% if getattr(master, 'cloneable', False) and master.has_perm('clone'): % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'):
<once-button tag="a" href="${master.get_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">

View file

@ -1,10 +1,7 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/base_meta.mako" />
<%def name="app_title()">${rattail_app.get_node_title()}</%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="extra_styles()"></%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'))}" />
@ -13,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

@ -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()}
@ -213,54 +309,34 @@
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 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
}
% 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,5 +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>

View file

@ -83,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
@ -99,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"
@ -162,7 +162,7 @@
</${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)">
@ -580,18 +580,27 @@
<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> 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> expanded>
</b-input> </b-input>
@ -606,7 +615,6 @@
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
@ -631,9 +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.updateConsumerDisabled = function() { ThisPage.computed.updateConsumerDisabled = function() {
if (!this.editingConsumerKey) { if (!this.editingConsumerKey) {
return true return true

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

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

@ -59,7 +59,7 @@
native-type="submit" native-type="submit"
:disabled="${form.vue_component}Submitting" :disabled="${form.vue_component}Submitting"
icon-pack="fas" icon-pack="fas"
icon-left="save"> icon-left="${form.button_icon_submit}">
{{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }} {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }}
</b-button> </b-button>
% else: % else:
@ -180,7 +180,7 @@
let ${form.vue_component}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},

View file

@ -10,8 +10,70 @@
<div style="display: flex; flex-direction: column; justify-content: end;"> <div style="display: flex; flex-direction: column; justify-content: end;">
<div class="filters"> <div class="filters">
% if getattr(grid, 'filterable', False): % 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>
@ -136,10 +198,8 @@
<${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.get('sortable', False))}" :sortable="${json.dumps(column.get('sortable', False))|n}"
% if hasattr(grid, 'is_searchable') and 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.get('visible', True))}"> :visible="${json.dumps(column.get('visible', True))}">
% if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers: % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers:
@ -251,12 +311,16 @@
<script type="text/javascript"> <script type="text/javascript">
let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_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.vue_component}Data = { let ${grid.vue_component}Data = {
loading: false, loading: false,
ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n}, ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n},
## nb. this tracks whether grid.fetchFirstData() happened
fetchedFirstData: false,
savingDefaults: false, savingDefaults: false,
data: ${grid.vue_component}CurrentData, data: ${grid.vue_component}CurrentData,
@ -519,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
@ -671,7 +746,7 @@
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) {

View file

@ -1,67 +0,0 @@
## -*- coding: utf-8; -*-
<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()">
<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>

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

@ -1,84 +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_vue_vars()">
${parent.modify_vue_vars()}
<script>
${form.vue_component}Data.usernameInput = null
${form.vue_component}.mounted = function() {
this.$refs.username.focus()
this.usernameInput = this.$refs.username.$el.querySelector('input')
this.usernameInput.addEventListener('keydown', this.usernameKeydown)
}
${form.vue_component}.beforeDestroy = function() {
this.usernameInput.removeEventListener('keydown', this.usernameKeydown)
}
${form.vue_component}.methods.usernameKeydown = function(event) {
if (event.which == 13) {
event.preventDefault()
this.$refs.password.focus()
}
}
</script>
</%def>

View file

@ -196,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"

View file

@ -0,0 +1,74 @@
## -*- coding: utf-8; -*-
<%inherit file="/configure.mako" />
<%def name="form_content()">
<h3 class="block is-size-3">Workflows</h3>
<div class="block" style="padding-left: 2rem;">
<p class="block">
Users can only choose from the workflows enabled below.
</p>
<b-field>
<b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch"
v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']"
native-value="true"
@input="settingsNeedSaved = true">
From Scratch
</b-checkbox>
</b-field>
<b-field>
<b-checkbox name="rattail.batch.purchase.allow_ordering_from_file"
v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']"
native-value="true"
@input="settingsNeedSaved = true">
From Order File
</b-checkbox>
</b-field>
</div>
<h3 class="block is-size-3">Vendors</h3>
<div class="block" style="padding-left: 2rem;">
<b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
<b-checkbox name="rattail.batch.purchase.allow_ordering_any_vendor"
v-model="simpleSettings['rattail.batch.purchase.allow_ordering_any_vendor']"
native-value="true"
@input="settingsNeedSaved = true">
Allow ordering for <span class="has-text-weight-bold">any</span> vendor
</b-checkbox>
</b-field>
</div>
<h3 class="block is-size-3">Order Parsers</h3>
<div class="block" style="padding-left: 2rem;">
<p class="block">
Only the selected file parsers will be exposed to users.
</p>
% for Parser in order_parsers:
<b-field message="${Parser.key}">
<b-checkbox name="order_parser_${Parser.key}"
v-model="orderParsers['${Parser.key}']"
native-value="true"
@input="settingsNeedSaved = true">
${Parser.title}
</b-checkbox>
</b-field>
% endfor
</div>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.orderParsers = ${json.dumps(order_parsers_data)|n}
</script>
</%def>

View file

@ -204,7 +204,7 @@
saving: false, saving: false,
## 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},
} }
}, },
computed: { computed: {

View file

@ -250,7 +250,7 @@
submitting: false, submitting: false,
## 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},
} }
}, },
methods: { methods: {

View file

@ -38,7 +38,7 @@
const ThisPageData = { const ThisPageData = {
## 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},
} }
</script> </script>

View file

@ -55,19 +55,20 @@
</%def> </%def>
<%def name="render_form_template()"> <%def name="render_form_template()">
<script type="text/x-template" id="${form.component}-template"> <script type="text/x-template" id="${form.vue_tagname}-template">
${self.render_form_innards()} ${self.render_form_innards()}
</script> </script>
</%def> </%def>
<%def name="modify_vue_vars()"> <%def name="modify_vue_vars()">
${parent.modify_vue_vars()} ${parent.modify_vue_vars()}
<% request.register_component(form.vue_tagname, form.vue_component) %>
<script> <script>
## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako)
let ${form.vue_component} = { let ${form.vue_component} = {
template: '#${form.component}-template', template: '#${form.vue_tagname}-template',
methods: { methods: {
## TODO: deprecate / remove the latter option here ## TODO: deprecate / remove the latter option here

View file

@ -69,12 +69,12 @@
<h3 class="block is-size-3">Vendors</h3> <h3 class="block is-size-3">Vendors</h3>
<div class="block" style="padding-left: 2rem;"> <div class="block" style="padding-left: 2rem;">
<b-field message="If set, user must choose a &quot;supported&quot; vendor; otherwise they may choose &quot;any&quot; vendor."> <b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
<b-checkbox name="rattail.batch.purchase.supported_vendors_only" <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor"
v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']" v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']"
native-value="true" native-value="true"
@input="settingsNeedSaved = true"> @input="settingsNeedSaved = true">
Only allow batch for "supported" vendors Allow receiving for <span class="has-text-weight-bold">any</span> vendor
</b-checkbox> </b-checkbox>
</b-field> </b-field>

View file

@ -45,11 +45,10 @@
<b-button @click="runReportShowDialog = false"> <b-button @click="runReportShowDialog = false">
Cancel Cancel
</b-button> </b-button>
${h.form(master.get_action_url('execute', instance))} ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})}
${h.csrf_token(request)} ${h.csrf_token(request)}
<b-button type="is-primary" <b-button type="is-primary"
native-type="submit" native-type="submit"
@click="runReportSubmitting = true"
:disabled="runReportSubmitting" :disabled="runReportSubmitting"
icon-pack="fas" icon-pack="fas"
icon-left="arrow-circle-right"> icon-left="arrow-circle-right">

View file

@ -909,7 +909,7 @@
${h.form(url('stop_root'), ref='stopBeingRootForm')} ${h.form(url('stop_root'), ref='stopBeingRootForm')}
${h.csrf_token(request)} ${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" /> <input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="stopBeingRoot()" <a @click="$refs.stopBeingRootForm.submit()"
class="navbar-item has-background-danger has-text-white"> class="navbar-item has-background-danger has-text-white">
Stop being root Stop being root
</a> </a>
@ -918,7 +918,7 @@
${h.form(url('become_root'), ref='startBeingRootForm')} ${h.form(url('become_root'), ref='startBeingRootForm')}
${h.csrf_token(request)} ${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" /> <input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="startBeingRoot()" <a @click="$refs.startBeingRootForm.submit()"
class="navbar-item has-background-danger has-text-white"> class="navbar-item has-background-danger has-text-white">
Become root Become root
</a> </a>
@ -1103,18 +1103,6 @@
const key = 'menu_' + hash + '_shown' const key = 'menu_' + hash + '_shown'
this[key] = !this[key] this[key] = !this[key]
}, },
% if request.is_admin:
startBeingRoot() {
this.$refs.startBeingRootForm.submit()
},
stopBeingRoot() {
this.$refs.stopBeingRootForm.submit()
},
% endif
}, },
} }

View file

@ -666,6 +666,7 @@
<%def name="make_b_tooltip_component()"> <%def name="make_b_tooltip_component()">
<script type="text/x-template" id="b-tooltip-template"> <script type="text/x-template" id="b-tooltip-template">
<o-tooltip :label="label" <o-tooltip :label="label"
:position="orugaPosition"
:multiline="multilined"> :multiline="multilined">
<slot /> <slot />
</o-tooltip> </o-tooltip>
@ -676,6 +677,14 @@
props: { props: {
label: String, label: String,
multilined: Boolean, multilined: Boolean,
position: String,
},
computed: {
orugaPosition() {
if (this.position) {
return this.position.replace(/^is-/, '')
}
},
}, },
} }
</script> </script>

View file

@ -7,6 +7,7 @@
<%def name="base_styles()"> <%def name="base_styles()">
${parent.base_styles()} ${parent.base_styles()}
${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
<style> <style>
.filters .filter-fieldname .field, .filters .filter-fieldname .field,
@ -50,9 +51,11 @@
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item" href="${url('home')}" <a class="navbar-item" href="${url('home')}"
v-show="!menuSearchActive"> v-show="!menuSearchActive">
${base_meta.header_logo()} <div style="display: flex; align-items: center;">
<div id="global-header-title"> ${base_meta.header_logo()}
${base_meta.global_title()} <div id="navbar-brand-title">
${base_meta.global_title()}
</div>
</div> </div>
</a> </a>
<div v-show="menuSearchActive" <div v-show="menuSearchActive"
@ -161,11 +164,88 @@
/> />
</div> </div>
% if request.has_perm('common.feedback'): ${parent.render_feedback_button()}
<feedback-form </%def>
action="${url('feedback')}"
:message="feedbackMessage"> <%def name="render_crud_header_buttons()">
</feedback-form> % if master:
% if master.viewing:
% if instance_editable and master.has_perm('edit'):
<wutta-button once
tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit"
label="Edit This" />
% endif
% if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'):
<wutta-button once
tag="a" href="${master.get_action_url('clone', instance)}"
icon-left="object-ungroup"
label="Clone This" />
% endif
% if instance_deletable and master.has_perm('delete'):
<wutta-button once type="is-danger"
tag="a" href="${master.get_action_url('delete', instance)}"
icon-left="trash"
label="Delete This" />
% endif
% elif master.editing:
% if master.has_perm('view'):
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"
label="View This" />
% endif
% if instance_deletable and master.has_perm('delete'):
<wutta-button once type="is-danger"
tag="a" href="${master.get_action_url('delete', instance)}"
icon-left="trash"
label="Delete This" />
% endif
% elif master.deleting:
% if master.has_perm('view'):
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"
label="View This" />
% endif
% if instance_editable and master.has_perm('edit'):
<wutta-button once
tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit"
label="Edit This" />
% endif
% endif
% endif
</%def>
<%def name="render_prevnext_header_buttons()">
% if show_prev_next is not Undefined and show_prev_next:
% if prev_url:
<wutta-button once
tag="a" href="${prev_url}"
icon-left="arrow-left"
label="Older" />
% else:
<b-button tag="a" href="#"
disabled
icon-pack="fas"
icon-left="arrow-left">
Older
</b-button>
% endif
% if next_url:
<wutta-button once
tag="a" href="${next_url}"
icon-left="arrow-right"
label="Newer" />
% else:
<b-button tag="a" href="#"
disabled
icon-pack="fas"
icon-left="arrow-right">
Newer
</b-button>
% endif
% endif % endif
</%def> </%def>
@ -177,174 +257,133 @@
/> />
</%def> </%def>
<%def name="render_vue_templates()"> <%def name="render_vue_template_feedback()">
${parent.render_vue_templates()} <script type="text/x-template" id="feedback-template">
<div>
${page_help.render_template()} <div class="level-item">
${page_help.declare_vars()} <b-button type="is-primary"
@click="showFeedback()"
icon-pack="fas"
icon-left="comment">
Feedback
</b-button>
</div>
% if request.has_perm('common.feedback'): <b-modal has-modal-card
<script type="text/x-template" id="feedback-template"> :active.sync="showDialog">
<div> <div class="modal-card">
<div class="level-item"> <header class="modal-card-head">
<b-button type="is-primary" <p class="modal-card-title">User Feedback</p>
@click="showFeedback()" </header>
icon-pack="fas"
icon-left="comment">
Feedback
</b-button>
</div>
<b-modal has-modal-card <section class="modal-card-body">
:active.sync="showDialog"> <p class="block">
<div class="modal-card"> Questions, suggestions, comments, complaints, etc.
<span class="red">regarding this website</span> are
welcome and may be submitted below.
</p>
<header class="modal-card-head"> <b-field label="User Name">
<p class="modal-card-title">User Feedback</p> <b-input v-model="userName"
</header> % if request.user:
disabled
% endif
>
</b-input>
</b-field>
<section class="modal-card-body"> <b-field label="Referring URL">
<p class="block"> <b-input
Questions, suggestions, comments, complaints, etc. v-model="referrer"
<span class="red">regarding this website</span> are disabled="true">
welcome and may be submitted below. </b-input>
</p> </b-field>
<b-field label="User Name"> <b-field label="Message">
<b-input v-model="userName" <b-input type="textarea"
% if request.user: v-model="message"
disabled ref="textarea">
% endif </b-input>
> </b-field>
</b-input>
</b-field>
<b-field label="Referring URL"> % if config.get_bool('tailbone.feedback_allows_reply'):
<b-input <div class="level">
v-model="referrer" <div class="level-left">
disabled="true"> <div class="level-item">
</b-input> <b-checkbox v-model="pleaseReply"
</b-field> @input="pleaseReplyChanged">
Please email me back{{ pleaseReply ? " at: " : "" }}
<b-field label="Message"> </b-checkbox>
<b-input type="textarea"
v-model="message"
ref="textarea">
</b-input>
</b-field>
% if config.get_bool('tailbone.feedback_allows_reply'):
<div class="level">
<div class="level-left">
<div class="level-item">
<b-checkbox v-model="pleaseReply"
@input="pleaseReplyChanged">
Please email me back{{ pleaseReply ? " at: " : "" }}
</b-checkbox>
</div>
<div class="level-item" v-show="pleaseReply">
<b-input v-model="userEmail"
ref="userEmail">
</b-input>
</div>
</div>
</div> </div>
% endif <div class="level-item" v-show="pleaseReply">
<b-input v-model="userEmail"
ref="userEmail">
</b-input>
</div>
</div>
</div>
% endif
</section> </section>
<footer class="modal-card-foot">
<b-button @click="showDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="paper-plane"
@click="sendFeedback()"
:disabled="sendingFeedback || !message.trim()">
{{ sendingFeedback ? "Working, please wait..." : "Send Message" }}
</b-button>
</footer>
</div>
</b-modal>
<footer class="modal-card-foot">
<b-button @click="showDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="paper-plane"
@click="sendFeedback()"
:disabled="sendingFeedback || !message || !message.trim()">
{{ sendingFeedback ? "Working, please wait..." : "Send Message" }}
</b-button>
</footer>
</div> </div>
</script> </b-modal>
<script>
const FeedbackForm = { </div>
template: '#feedback-template', </script>
mixins: [SimpleRequestMixin], </%def>
props: [
'action',
'message',
],
methods: {
showFeedback() { <%def name="render_vue_script_feedback()">
this.referrer = location.href ${parent.render_vue_script_feedback()}
this.showDialog = true <script>
this.$nextTick(function() {
this.$refs.textarea.focus()
})
},
% if config.get_bool('tailbone.feedback_allows_reply'): WuttaFeedbackForm.template = '#feedback-template'
pleaseReplyChanged(value) { WuttaFeedbackForm.props.message = String
this.$nextTick(() => {
this.$refs.userEmail.focus()
})
},
% endif
sendFeedback() { % if config.get_bool('tailbone.feedback_allows_reply'):
this.sendingFeedback = true
const params = { WuttaFeedbackFormData.pleaseReply = false
referrer: this.referrer, WuttaFeedbackFormData.userEmail = null
user: this.userUUID,
user_name: this.userName,
% if config.get_bool('tailbone.feedback_allows_reply'):
please_reply_to: this.pleaseReply ? this.userEmail : null,
% endif
message: this.message.trim(),
}
this.simplePOST(this.action, params, response => { WuttaFeedbackForm.methods.pleaseReplyChanged = function(value) {
this.$nextTick(() => {
this.$refs.userEmail.focus()
})
}
this.$buefy.toast.open({ WuttaFeedbackForm.methods.getExtraParams = function() {
message: "Message sent! Thank you for your feedback.", return {
type: 'is-info', please_reply_to: this.pleaseReply ? this.userEmail : null,
duration: 4000, // 4 seconds
})
this.showDialog = false
// clear out message, in case they need to send another
this.message = ""
this.sendingFeedback = false
}, response => { // failure
this.sendingFeedback = false
})
},
} }
} }
const FeedbackFormData = { % endif
referrer: null,
userUUID: null,
userName: null,
userEmail: null,
% if config.get_bool('tailbone.feedback_allows_reply'):
pleaseReply: false,
% endif
showDialog: false,
sendingFeedback: false,
}
</script> // TODO: deprecate / remove these
% endif const FeedbackForm = WuttaFeedbackForm
const FeedbackFormData = WuttaFeedbackFormData
</script>
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
${page_help.render_template()}
${page_help.declare_vars()}
</%def> </%def>
<%def name="modify_vue_vars()"> <%def name="modify_vue_vars()">
@ -443,21 +482,6 @@
% endif % endif
##############################
## feedback
##############################
% if request.has_perm('common.feedback'):
WholePageData.feedbackMessage = ""
% if request.user:
FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
FeedbackFormData.userName = ${json.dumps(str(request.user))|n}
% endif
% endif
############################## ##############################
## edit fields help ## edit fields help
############################## ##############################
@ -477,10 +501,4 @@
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')} ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')}
${make_grid_filter_components()} ${make_grid_filter_components()}
${page_help.make_component()} ${page_help.make_component()}
% if request.has_perm('common.feedback'):
<script>
FeedbackForm.data = function() { return FeedbackFormData }
Vue.component('feedback-form', FeedbackForm)
</script>
% endif
</%def> </%def>

View file

@ -1,2 +1,78 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/configure.mako" /> <%inherit file="wuttaweb:templates/configure.mako" />
<%namespace name="tailbone_base" file="tailbone:templates/configure.mako" />
<%def name="input_file_templates_section()">
${tailbone_base.input_file_templates_section()}
</%def>
<%def name="output_file_templates_section()">
${tailbone_base.output_file_templates_section()}
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
##############################
## 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>
</%def>

View file

@ -1,2 +1,10 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/form.mako" /> <%inherit file="wuttaweb:templates/form.mako" />
<%def name="render_vue_template_form()">
% if form is not Undefined:
${form.render_vue_template(buttons=capture(self.render_form_buttons))}
% endif
</%def>
<%def name="render_form_buttons()"></%def>

View file

@ -254,6 +254,11 @@
</%def> </%def>
## DEPRECATED; remains for back-compat
<%def name="render_this_page()">
${self.page_content()}
</%def>
<%def name="render_vue_template_grid()"> <%def name="render_vue_template_grid()">
${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
</%def> </%def>

View file

@ -38,7 +38,7 @@
${parent.modify_vue_vars()} ${parent.modify_vue_vars()}
<script> <script>
ThisPageData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} ThisPageData.csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
% if can_edit_help: % if can_edit_help:
ThisPage.props.configureFieldsHelp = Boolean ThisPage.props.configureFieldsHelp = Boolean

View file

@ -41,7 +41,9 @@ from webhelpers2.html import HTML, tags
from wuttaweb.util import (get_form_data as wutta_get_form_data, from wuttaweb.util import (get_form_data as wutta_get_form_data,
get_libver as wutta_get_libver, get_libver as wutta_get_libver,
get_liburl as wutta_get_liburl) get_liburl as wutta_get_liburl,
get_csrf_token as wutta_get_csrf_token,
render_csrf_token)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -59,22 +61,19 @@ class SortColumn(object):
def get_csrf_token(request): def get_csrf_token(request):
""" """ """
Convenience function to retrieve the effective CSRF token for the given warnings.warn("tailbone.util.get_csrf_token() is deprecated; "
request. "please use wuttaweb.util.get_csrf_token() instead",
""" DeprecationWarning, stacklevel=2)
token = request.session.get_csrf_token() return wutta_get_csrf_token(request)
if token is None:
token = request.session.new_csrf_token()
return token
def csrf_token(request, name='_csrf'): def csrf_token(request, name='_csrf'):
""" """ """
Convenience function. Returns CSRF hidden tag inside hidden DIV. warnings.warn("tailbone.util.csrf_token() is deprecated; "
""" "please use wuttaweb.util.render_csrf_token() instead",
token = get_csrf_token(request) DeprecationWarning, stacklevel=2)
return HTML.tag("div", tags.hidden(name, value=token), style="display:none;") return render_csrf_token(request, name=name)
def get_form_data(request): def get_form_data(request):

View file

@ -24,8 +24,6 @@
Auth Views Auth Views
""" """
from rattail.db.auth import set_user_password
import colander import colander
from deform import widget as dfwidget from deform import widget as dfwidget
from pyramid.httpexceptions import HTTPForbidden from pyramid.httpexceptions import HTTPForbidden
@ -46,28 +44,6 @@ class UserLogin(colander.MappingSchema):
widget=dfwidget.PasswordWidget()) widget=dfwidget.PasswordWidget())
@colander.deferred
def current_password_correct(node, kw):
request = kw['request']
app = request.rattail_config.get_app()
auth = app.get_auth_handler()
user = kw['user']
def validate(node, value):
if not auth.authenticate_user(Session(), user.username, value):
raise colander.Invalid(node, "The password is incorrect")
return validate
class ChangePassword(colander.MappingSchema):
current_password = colander.SchemaNode(colander.String(),
widget=dfwidget.PasswordWidget(),
validator=current_password_correct)
new_password = colander.SchemaNode(colander.String(),
widget=dfwidget.CheckedPasswordWidget())
class AuthenticationView(View): class AuthenticationView(View):
def forbidden(self): def forbidden(self):
@ -104,6 +80,7 @@ class AuthenticationView(View):
form.save_label = "Login" form.save_label = "Login"
form.show_reset = True form.show_reset = True
form.show_cancel = False form.show_cancel = False
form.button_icon_submit = 'user'
if form.validate(): if form.validate():
user = self.authenticate_user(form.validated['username'], user = self.authenticate_user(form.validated['username'],
form.validated['password']) form.validated['password'])
@ -117,10 +94,6 @@ class AuthenticationView(View):
else: else:
self.request.session.flash("Invalid username or password", 'error') self.request.session.flash("Invalid username or password", 'error')
image_url = self.rattail_config.get(
'tailbone', 'main_image_url',
default=self.request.static_url('tailbone:static/img/home_logo.png'))
# nb. hacky..but necessary, to add the refs, for autofocus # nb. hacky..but necessary, to add the refs, for autofocus
# (also add key handler, so ENTER acts like TAB) # (also add key handler, so ENTER acts like TAB)
dform = form.make_deform_form() dform = form.make_deform_form()
@ -133,7 +106,6 @@ class AuthenticationView(View):
return { return {
'form': form, 'form': form,
'referrer': referrer, 'referrer': referrer,
'image_url': image_url,
'index_title': app.get_node_title(), 'index_title': app.get_node_title(),
'help_url': global_help_url(self.rattail_config), 'help_url': global_help_url(self.rattail_config),
} }
@ -182,10 +154,27 @@ class AuthenticationView(View):
self.request.user)) self.request.user))
return self.redirect(self.request.get_referrer()) return self.redirect(self.request.get_referrer())
schema = ChangePassword().bind(user=self.request.user, request=self.request) def check_user_password(node, value):
auth = self.app.get_auth_handler()
user = self.request.user
if not auth.check_user_password(user, value):
node.raise_invalid("The password is incorrect")
schema = colander.Schema()
schema.add(colander.SchemaNode(colander.String(),
name='current_password',
widget=dfwidget.PasswordWidget(),
validator=check_user_password))
schema.add(colander.SchemaNode(colander.String(),
name='new_password',
widget=dfwidget.CheckedPasswordWidget()))
form = forms.Form(schema=schema, request=self.request) form = forms.Form(schema=schema, request=self.request)
if form.validate(): if form.validate():
set_user_password(self.request.user, form.validated['new_password']) auth = self.app.get_auth_handler()
auth.set_user_password(self.request.user, form.validated['new_password'])
self.request.session.flash("Your password has been changed.") self.request.session.flash("Your password has been changed.")
return self.redirect(self.request.get_referrer()) return self.redirect(self.request.get_referrer())

View file

@ -46,10 +46,11 @@ import colander
from deform import widget as dfwidget from deform import widget as dfwidget
from webhelpers2.html import HTML, tags from webhelpers2.html import HTML, tags
from wuttaweb.util import render_csrf_token
from tailbone import forms, grids from tailbone import forms, grids
from tailbone.db import Session from tailbone.db import Session
from tailbone.views import MasterView from tailbone.views import MasterView
from tailbone.util import csrf_token
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -383,7 +384,7 @@ class BatchMasterView(MasterView):
f.set_label('executed_by', "Executed by") f.set_label('executed_by', "Executed by")
# notes # notes
f.set_type('notes', 'text') f.set_type('notes', 'text_wrapped')
# if self.creating and self.request.user: # if self.creating and self.request.user:
# batch = fs.model # batch = fs.model
@ -441,7 +442,7 @@ class BatchMasterView(MasterView):
form = [ form = [
begin_form, begin_form,
csrf_token(self.request), render_csrf_token(self.request),
tags.hidden('complete', value=value), tags.hidden('complete', value=value),
submit, submit,
tags.end_form(), tags.end_form(),
@ -861,7 +862,7 @@ class BatchMasterView(MasterView):
if not schema: if not schema:
schema = colander.Schema() schema = colander.Schema()
kwargs['component'] = 'execute-form' kwargs['vue_tagname'] = 'execute-form'
form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs)
self.configure_execute_form(form) self.configure_execute_form(form)
return form return form

View file

@ -25,6 +25,7 @@ Various common views
""" """
import os import os
import warnings
from collections import OrderedDict from collections import OrderedDict
from rattail.batch import consume_batch_id from rattail.batch import consume_batch_id
@ -50,13 +51,31 @@ class CommonView(View):
Home page view. Home page view.
""" """
app = self.get_rattail_app() app = self.get_rattail_app()
if not self.request.user:
if self.rattail_config.getbool('tailbone', 'login_is_home', default=True):
raise self.redirect(self.request.route_url('login'))
image_url = self.rattail_config.get( # maybe auto-redirect anons to login
'tailbone', 'main_image_url', if not self.request.user:
default=self.request.static_url('tailbone:static/img/home_logo.png')) redirect = self.config.get_bool('wuttaweb.home_redirect_to_login')
if redirect is None:
redirect = self.config.get_bool('tailbone.login_is_home')
if redirect is not None:
warnings.warn("tailbone.login_is_home setting is deprecated; "
"please set wuttaweb.home_redirect_to_login instead",
DeprecationWarning)
else:
# TODO: this is opposite of upstream default, should change
redirect = True
if redirect:
return self.redirect(self.request.route_url('login'))
image_url = self.config.get('wuttaweb.logo_url')
if not image_url:
image_url = self.config.get('tailbone.main_image_url')
if image_url:
warnings.warn("tailbone.main_image_url setting is deprecated; "
"please set wuttaweb.logo_url instead",
DeprecationWarning)
else:
image_url = self.request.static_url('tailbone:static/img/home_logo.png')
context = { context = {
'image_url': image_url, 'image_url': image_url,

View file

@ -202,10 +202,36 @@ class DataSyncThreadView(MasterView):
return self.redirect(self.request.get_referrer( return self.redirect(self.request.get_referrer(
default=self.request.route_url('datasyncchanges'))) default=self.request.route_url('datasyncchanges')))
def configure_get_context(self): def configure_get_simple_settings(self):
""" """
return [
# basic
{'section': 'rattail.datasync',
'option': 'use_profile_settings',
'type': bool},
# misc.
{'section': 'rattail.datasync',
'option': 'supervisor_process_name'},
{'section': 'rattail.datasync',
'option': 'batch_size_limit',
'type': int},
# legacy
{'section': 'tailbone',
'option': 'datasync.restart'},
]
def configure_get_context(self, **kwargs):
""" """
context = super().configure_get_context(**kwargs)
profiles = self.datasync_handler.get_configured_profiles( profiles = self.datasync_handler.get_configured_profiles(
include_disabled=True, include_disabled=True,
ignore_problems=True) ignore_problems=True)
context['profiles'] = profiles
profiles_data = [] profiles_data = []
for profile in sorted(profiles.values(), key=lambda p: p.key): for profile in sorted(profiles.values(), key=lambda p: p.key):
@ -243,25 +269,15 @@ class DataSyncThreadView(MasterView):
data['consumers_data'] = consumers data['consumers_data'] = consumers
profiles_data.append(data) profiles_data.append(data)
return { context['profiles_data'] = profiles_data
'profiles': profiles, return context
'profiles_data': profiles_data,
'use_profile_settings': self.datasync_handler.should_use_profile_settings(),
'supervisor_process_name': self.rattail_config.get(
'rattail.datasync', 'supervisor_process_name'),
'restart_command': self.rattail_config.get(
'tailbone', 'datasync.restart'),
}
def configure_gather_settings(self, data): def configure_gather_settings(self, data, **kwargs):
settings = [] """ """
watch = [] settings = super().configure_gather_settings(data, **kwargs)
use_profile_settings = data.get('use_profile_settings') == 'true' if data.get('rattail.datasync.use_profile_settings') == 'true':
settings.append({'name': 'rattail.datasync.use_profile_settings', watch = []
'value': 'true' if use_profile_settings else 'false'})
if use_profile_settings:
for profile in json.loads(data['profiles']): for profile in json.loads(data['profiles']):
pkey = profile['key'] pkey = profile['key']
@ -323,17 +339,12 @@ class DataSyncThreadView(MasterView):
settings.append({'name': 'rattail.datasync.watch', settings.append({'name': 'rattail.datasync.watch',
'value': ', '.join(watch)}) 'value': ', '.join(watch)})
if data['supervisor_process_name']:
settings.append({'name': 'rattail.datasync.supervisor_process_name',
'value': data['supervisor_process_name']})
if data['restart_command']:
settings.append({'name': 'tailbone.datasync.restart',
'value': data['restart_command']})
return settings return settings
def configure_remove_settings(self): def configure_remove_settings(self, **kwargs):
""" """
super().configure_remove_settings(**kwargs)
purge_datasync_settings(self.rattail_config, self.Session()) purge_datasync_settings(self.rattail_config, self.Session())
@classmethod @classmethod

View file

@ -116,11 +116,12 @@ class EmailSettingView(MasterView):
return data return data
def configure_grid(self, g): def configure_grid(self, g):
g.sorters['key'] = g.make_simple_sorter('key', foldcase=True) super().configure_grid(g)
g.sorters['prefix'] = g.make_simple_sorter('prefix', foldcase=True)
g.sorters['subject'] = g.make_simple_sorter('subject', foldcase=True) g.sort_on_backend = False
g.sorters['enabled'] = g.make_simple_sorter('enabled') g.sort_multiple = False
g.set_sort_defaults('key') g.set_sort_defaults('key')
g.set_type('enabled', 'boolean') g.set_type('enabled', 'boolean')
g.set_link('key') g.set_link('key')
g.set_link('subject') g.set_link('subject')
@ -130,11 +131,9 @@ class EmailSettingView(MasterView):
# to # to
g.set_renderer('to', self.render_to_short) g.set_renderer('to', self.render_to_short)
g.sorters['to'] = g.make_simple_sorter('to', foldcase=True)
# hidden # hidden
if self.has_perm('configure'): if self.has_perm('configure'):
g.sorters['hidden'] = g.make_simple_sorter('hidden')
g.set_type('hidden', 'boolean') g.set_type('hidden', 'boolean')
else: else:
g.remove('hidden') g.remove('hidden')

View file

@ -117,6 +117,7 @@ class MasterView(View):
supports_prev_next = False supports_prev_next = False
supports_import_batch_from_file = False supports_import_batch_from_file = False
has_input_file_templates = False has_input_file_templates = False
has_output_file_templates = False
configurable = False configurable = False
# set to True to add "View *global* Objects" permission, and # set to True to add "View *global* Objects" permission, and
@ -334,7 +335,7 @@ class MasterView(View):
# If user just refreshed the page with a reset instruction, issue a # If user just refreshed the page with a reset instruction, issue a
# redirect in order to clear out the query string. # redirect in order to clear out the query string.
if self.request.GET.get('reset-to-default-filters') == 'true': if self.request.GET.get('reset-view'):
kw = {'_query': None} kw = {'_query': None}
hash_ = self.request.GET.get('hash') hash_ = self.request.GET.get('hash')
if hash_: if hash_:
@ -411,7 +412,7 @@ class MasterView(View):
session = self.Session() session = self.Session()
kwargs.setdefault('paginated', False) kwargs.setdefault('paginated', False)
grid = self.make_grid(session=session, **kwargs) grid = self.make_grid(session=session, **kwargs)
return grid.make_visible_data() return grid.get_visible_data()
def get_grid_columns(self): def get_grid_columns(self):
""" """
@ -550,7 +551,8 @@ class MasterView(View):
def get_quickie_result_url(self, obj): def get_quickie_result_url(self, obj):
return self.get_action_url('view', obj) return self.get_action_url('view', obj)
def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): def make_row_grid(self, factory=None, key=None, data=None, columns=None,
session=None, **kwargs):
""" """
Make and return a new (configured) rows grid instance. Make and return a new (configured) rows grid instance.
""" """
@ -611,7 +613,9 @@ class MasterView(View):
# delete action # delete action
if self.rows_deletable and self.has_perm('delete_row'): if self.rows_deletable and self.has_perm('delete_row'):
actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) actions.append(self.make_action('delete', icon='trash',
url=self.row_delete_action_url,
link_class='has-text-danger'))
defaults['delete_speedbump'] = self.rows_deletable_speedbump defaults['delete_speedbump'] = self.rows_deletable_speedbump
defaults['actions'] = actions defaults['actions'] = actions
@ -899,7 +903,7 @@ class MasterView(View):
def valid_employee_uuid(self, node, value): def valid_employee_uuid(self, node, value):
if value: if value:
model = self.model model = self.app.model
employee = self.Session.get(model.Employee, value) employee = self.Session.get(model.Employee, value)
if not employee: if not employee:
node.raise_invalid("Employee not found") node.raise_invalid("Employee not found")
@ -935,7 +939,7 @@ class MasterView(View):
def valid_vendor_uuid(self, node, value): def valid_vendor_uuid(self, node, value):
if value: if value:
model = self.model model = self.app.model
vendor = self.Session.get(model.Vendor, value) vendor = self.Session.get(model.Vendor, value)
if not vendor: if not vendor:
node.raise_invalid("Vendor not found") node.raise_invalid("Vendor not found")
@ -1183,7 +1187,7 @@ class MasterView(View):
# If user just refreshed the page with a reset instruction, issue a # If user just refreshed the page with a reset instruction, issue a
# redirect in order to clear out the query string. # redirect in order to clear out the query string.
if self.request.GET.get('reset-to-default-filters') == 'true': if self.request.GET.get('reset-view'):
kw = {'_query': None} kw = {'_query': None}
hash_ = self.request.GET.get('hash') hash_ = self.request.GET.get('hash')
if hash_: if hash_:
@ -1378,7 +1382,7 @@ class MasterView(View):
return classes return classes
def make_revisions_grid(self, obj, empty_data=False): def make_revisions_grid(self, obj, empty_data=False):
model = self.model model = self.app.model
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version', row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version',
uuid=obj.uuid, uuid=obj.uuid,
@ -1706,7 +1710,7 @@ class MasterView(View):
kwargs.setdefault('paginated', False) kwargs.setdefault('paginated', False)
kwargs.setdefault('sortable', sort) kwargs.setdefault('sortable', sort)
grid = self.make_row_grid(session=session, **kwargs) grid = self.make_row_grid(session=session, **kwargs)
return grid.make_visible_data() return grid.get_visible_data()
@classmethod @classmethod
def get_row_url_prefix(cls): def get_row_url_prefix(cls):
@ -1820,6 +1824,26 @@ class MasterView(View):
path = os.path.join(basedir, filespec) path = os.path.join(basedir, filespec)
return self.file_response(path) return self.file_response(path)
def download_output_file_template(self):
"""
View for downloading an output file template.
"""
key = self.request.GET['key']
filespec = self.request.GET['file']
matches = [tmpl for tmpl in self.get_output_file_templates()
if tmpl['key'] == key]
if not matches:
raise self.notfound()
template = matches[0]
templatesdir = os.path.join(self.rattail_config.datadir(),
'templates', 'output_files',
self.get_route_prefix())
basedir = os.path.join(templatesdir, template['key'])
path = os.path.join(basedir, filespec)
return self.file_response(path)
def edit(self): def edit(self):
""" """
View for editing an existing model record. View for editing an existing model record.
@ -2129,7 +2153,7 @@ class MasterView(View):
Thread target for executing an object. Thread target for executing an object.
""" """
app = self.get_rattail_app() app = self.get_rattail_app()
model = self.model model = self.app.model
session = app.make_session() session = app.make_session()
obj = self.get_instance_for_key(key, session) obj = self.get_instance_for_key(key, session)
user = session.get(model.User, user_uuid) user = session.get(model.User, user_uuid)
@ -2570,7 +2594,7 @@ class MasterView(View):
""" """
# nb. self.Session may differ, so use tailbone.db.Session # nb. self.Session may differ, so use tailbone.db.Session
session = Session() session = Session()
model = self.model model = self.app.model
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
info = session.query(model.TailbonePageHelp)\ info = session.query(model.TailbonePageHelp)\
@ -2593,7 +2617,7 @@ class MasterView(View):
""" """
# nb. self.Session may differ, so use tailbone.db.Session # nb. self.Session may differ, so use tailbone.db.Session
session = Session() session = Session()
model = self.model model = self.app.model
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
info = session.query(model.TailbonePageHelp)\ info = session.query(model.TailbonePageHelp)\
@ -2615,7 +2639,7 @@ class MasterView(View):
# nb. self.Session may differ, so use tailbone.db.Session # nb. self.Session may differ, so use tailbone.db.Session
session = Session() session = Session()
model = self.model model = self.app.model
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
schema = colander.Schema() schema = colander.Schema()
@ -2649,7 +2673,7 @@ class MasterView(View):
# nb. self.Session may differ, so use tailbone.db.Session # nb. self.Session may differ, so use tailbone.db.Session
session = Session() session = Session()
model = self.model model = self.app.model
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
schema = colander.Schema() schema = colander.Schema()
@ -2848,6 +2872,12 @@ class MasterView(View):
kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl) kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl)
for tmpl in templates]) for tmpl in templates])
# add info for downloadable output file templates, if any
if self.has_output_file_templates:
templates = self.normalize_output_file_templates()
kwargs['output_file_templates'] = OrderedDict([(tmpl['key'], tmpl)
for tmpl in templates])
return kwargs return kwargs
def get_input_file_templates(self): def get_input_file_templates(self):
@ -2922,6 +2952,81 @@ class MasterView(View):
return templates return templates
def get_output_file_templates(self):
return []
def normalize_output_file_templates(self, templates=None,
include_file_options=False):
if templates is None:
templates = self.get_output_file_templates()
route_prefix = self.get_route_prefix()
if include_file_options:
templatesdir = os.path.join(self.rattail_config.datadir(),
'templates', 'output_files',
route_prefix)
for template in templates:
if 'config_section' not in template:
if hasattr(self, 'output_file_template_config_section'):
template['config_section'] = self.output_file_template_config_section
else:
template['config_section'] = route_prefix
section = template['config_section']
if 'config_prefix' not in template:
template['config_prefix'] = '{}.{}'.format(
self.output_file_template_config_prefix,
template['key'])
prefix = template['config_prefix']
for key in ('mode', 'file', 'url'):
if 'option_{}'.format(key) not in template:
template['option_{}'.format(key)] = '{}.{}'.format(prefix, key)
if 'setting_{}'.format(key) not in template:
template['setting_{}'.format(key)] = '{}.{}'.format(
section,
template['option_{}'.format(key)])
if key not in template:
value = self.rattail_config.get(
section,
template['option_{}'.format(key)])
if value is not None:
template[key] = value
template.setdefault('mode', 'default')
template.setdefault('file', None)
template.setdefault('url', template['default_url'])
if include_file_options:
options = []
basedir = os.path.join(templatesdir, template['key'])
if os.path.exists(basedir):
for name in sorted(os.listdir(basedir)):
if len(name) == 4 and name.isdigit():
files = os.listdir(os.path.join(basedir, name))
if len(files) == 1:
options.append(os.path.join(name, files[0]))
template['file_options'] = options
template['file_options_dir'] = basedir
if template['mode'] == 'external':
template['effective_url'] = template['url']
elif template['mode'] == 'hosted':
template['effective_url'] = self.request.route_url(
'{}.download_output_file_template'.format(route_prefix),
_query={'key': template['key'],
'file': template['file']})
else:
template['effective_url'] = template['default_url']
return templates
def template_kwargs_index(self, **kwargs): def template_kwargs_index(self, **kwargs):
""" """
Method stub, so subclass can always invoke super() for it. Method stub, so subclass can always invoke super() for it.
@ -2969,6 +3074,12 @@ class MasterView(View):
items.append(tags.link_to(f"Download {template['label']} Template", items.append(tags.link_to(f"Download {template['label']} Template",
template['effective_url'])) template['effective_url']))
if self.has_output_file_templates and self.has_perm('configure'):
templates = self.normalize_output_file_templates()
for template in templates:
items.append(tags.link_to(f"Download {template['label']} Template",
template['effective_url']))
# if self.viewing: # if self.viewing:
# # # TODO: either make this configurable, or just lose it. # # # TODO: either make this configurable, or just lose it.
@ -3214,7 +3325,7 @@ class MasterView(View):
url=self.default_clone_url) url=self.default_clone_url)
def make_grid_action_delete(self): def make_grid_action_delete(self):
kwargs = {} kwargs = {'link_class': 'has-text-danger'}
if self.delete_confirm == 'simple': if self.delete_confirm == 'simple':
kwargs['click_handler'] = 'deleteObject' kwargs['click_handler'] = 'deleteObject'
return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs) return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs)
@ -5204,6 +5315,39 @@ class MasterView(View):
data[template['setting_file']] = os.path.join(numdir, data[template['setting_file']] = os.path.join(numdir,
info['filename']) info['filename'])
if self.has_output_file_templates:
templatesdir = os.path.join(self.rattail_config.datadir(),
'templates', 'output_files',
self.get_route_prefix())
def get_next_filedir(basedir):
nextid = 1
while True:
path = os.path.join(basedir, '{:04d}'.format(nextid))
if not os.path.exists(path):
# this should fail if there happens to be a race
# condition and someone else got to this id first
os.mkdir(path)
return path
nextid += 1
for template in self.normalize_output_file_templates():
key = '{}.upload'.format(template['setting_file'])
if key in uploads:
assert self.request.POST[template['setting_mode']] == 'hosted'
assert not self.request.POST[template['setting_file']]
info = uploads[key]
basedir = os.path.join(templatesdir, template['key'])
if not os.path.exists(basedir):
os.makedirs(basedir)
filedir = get_next_filedir(basedir)
filepath = os.path.join(filedir, info['filename'])
shutil.copyfile(info['filepath'], filepath)
shutil.rmtree(info['filedir'])
numdir = os.path.basename(filedir)
data[template['setting_file']] = os.path.join(numdir,
info['filename'])
def configure_get_simple_settings(self): def configure_get_simple_settings(self):
""" """
If you have some "simple" settings, each of which basically If you have some "simple" settings, each of which basically
@ -5248,7 +5392,8 @@ class MasterView(View):
simple['option']) simple['option'])
def configure_get_context(self, simple_settings=None, def configure_get_context(self, simple_settings=None,
input_file_templates=True): input_file_templates=True,
output_file_templates=True):
""" """
Returns the full context dict, for rendering the configure Returns the full context dict, for rendering the configure
page template. page template.
@ -5297,7 +5442,7 @@ class MasterView(View):
for template in self.normalize_input_file_templates( for template in self.normalize_input_file_templates(
include_file_options=True): include_file_options=True):
settings[template['setting_mode']] = template['mode'] settings[template['setting_mode']] = template['mode']
settings[template['setting_file']] = template['file'] settings[template['setting_file']] = template['file'] or ''
settings[template['setting_url']] = template['url'] settings[template['setting_url']] = template['url']
file_options[template['key']] = template['file_options'] file_options[template['key']] = template['file_options']
file_option_dirs[template['key']] = template['file_options_dir'] file_option_dirs[template['key']] = template['file_options_dir']
@ -5305,10 +5450,27 @@ class MasterView(View):
context['input_file_options'] = file_options context['input_file_options'] = file_options
context['input_file_option_dirs'] = file_option_dirs context['input_file_option_dirs'] = file_option_dirs
# add settings for output file templates, if any
if output_file_templates and self.has_output_file_templates:
settings = {}
file_options = {}
file_option_dirs = {}
for template in self.normalize_output_file_templates(
include_file_options=True):
settings[template['setting_mode']] = template['mode']
settings[template['setting_file']] = template['file'] or ''
settings[template['setting_url']] = template['url']
file_options[template['key']] = template['file_options']
file_option_dirs[template['key']] = template['file_options_dir']
context['output_file_template_settings'] = settings
context['output_file_options'] = file_options
context['output_file_option_dirs'] = file_option_dirs
return context return context
def configure_gather_settings(self, data, simple_settings=None, def configure_gather_settings(self, data, simple_settings=None,
input_file_templates=True): input_file_templates=True,
output_file_templates=True):
settings = [] settings = []
# maybe collect "simple" settings # maybe collect "simple" settings
@ -5354,12 +5516,32 @@ class MasterView(View):
settings.append({'name': template['setting_url'], settings.append({'name': template['setting_url'],
'value': data.get(template['setting_url'])}) 'value': data.get(template['setting_url'])})
# maybe also collect output file template settings
if output_file_templates and self.has_output_file_templates:
for template in self.normalize_output_file_templates():
# mode
settings.append({'name': template['setting_mode'],
'value': data.get(template['setting_mode'])})
# file
value = data.get(template['setting_file'])
if value:
# nb. avoid saving if empty, so can remain "null"
settings.append({'name': template['setting_file'],
'value': value})
# url
settings.append({'name': template['setting_url'],
'value': data.get(template['setting_url'])})
return settings return settings
def configure_remove_settings(self, simple_settings=None, def configure_remove_settings(self, simple_settings=None,
input_file_templates=True): input_file_templates=True,
output_file_templates=True):
app = self.get_rattail_app() app = self.get_rattail_app()
model = self.model model = self.app.model
names = [] names = []
if simple_settings is None: if simple_settings is None:
@ -5376,6 +5558,14 @@ class MasterView(View):
template['setting_url'], template['setting_url'],
]) ])
if output_file_templates and self.has_output_file_templates:
for template in self.normalize_output_file_templates():
names.extend([
template['setting_mode'],
template['setting_file'],
template['setting_url'],
])
if names: if names:
# nb. using thread-local session here; we do not use # nb. using thread-local session here; we do not use
# self.Session b/c it may not point to Rattail # self.Session b/c it may not point to Rattail
@ -5638,6 +5828,15 @@ class MasterView(View):
route_name='{}.download_input_file_template'.format(route_prefix), route_name='{}.download_input_file_template'.format(route_prefix),
permission='{}.create'.format(permission_prefix)) permission='{}.create'.format(permission_prefix))
# download output file template
if cls.has_output_file_templates and cls.configurable:
config.add_route(f'{route_prefix}.download_output_file_template',
f'{url_prefix}/download-output-file-template')
config.add_view(cls, attr='download_output_file_template',
route_name=f'{route_prefix}.download_output_file_template',
# TODO: this is different from input file, should change?
permission=f'{permission_prefix}.configure')
# view # view
if cls.viewable: if cls.viewable:
cls._defaults_view(config) cls._defaults_view(config)
@ -5901,7 +6100,7 @@ class MasterView(View):
renderer='json') renderer='json')
class ViewSupplement(object): class ViewSupplement:
""" """
Base class for view "supplements" - which are sort of like plugins Base class for view "supplements" - which are sort of like plugins
which can "supplement" certain aspects of the view. which can "supplement" certain aspects of the view.
@ -5928,6 +6127,7 @@ class ViewSupplement(object):
def __init__(self, master): def __init__(self, master):
self.master = master self.master = master
self.request = master.request self.request = master.request
self.app = master.app
self.model = master.model self.model = master.model
self.rattail_config = master.rattail_config self.rattail_config = master.rattail_config
self.Session = master.Session self.Session = master.Session
@ -5961,7 +6161,7 @@ class ViewSupplement(object):
This is accomplished by subjecting the current base query to a This is accomplished by subjecting the current base query to a
join, e.g. something like:: join, e.g. something like::
model = self.model model = self.app.model
query = query.outerjoin(model.MyExtension) query = query.outerjoin(model.MyExtension)
return query return query
""" """

View file

@ -564,15 +564,19 @@ class PersonView(MasterView):
Method which must return the base query for the profile's POS Method which must return the base query for the profile's POS
Transactions grid data. Transactions grid data.
""" """
app = self.get_rattail_app() customer = self.app.get_customer(person)
customer = app.get_customer(person)
key_field = app.get_customer_key_field() if customer:
customer_key = getattr(customer, key_field) key_field = self.app.get_customer_key_field()
if customer_key is not None: customer_key = getattr(customer, key_field)
customer_key = str(customer_key) if customer_key is not None:
customer_key = str(customer_key)
else:
# nb. this should *not* match anything, so query returns
# no results..
customer_key = person.uuid
trainwreck = app.get_trainwreck_handler() trainwreck = self.app.get_trainwreck_handler()
model = trainwreck.get_model() model = trainwreck.get_model()
query = TrainwreckSession.query(model.Transaction)\ query = TrainwreckSession.query(model.Transaction)\
.filter(model.Transaction.customer_id == customer_key) .filter(model.Transaction.customer_id == customer_key)
@ -1382,8 +1386,8 @@ class PersonView(MasterView):
} }
if not context['users']: if not context['users']:
context['suggested_username'] = auth.generate_unique_username(self.Session(), context['suggested_username'] = auth.make_unique_username(self.Session(),
person=person) person=person)
return context return context

View file

@ -34,7 +34,7 @@ import sqlalchemy_continuum as continuum
from rattail import enum, pod, sil from rattail import enum, pod, sil
from rattail.db import api, auth, Session as RattailSession from rattail.db import api, auth, Session as RattailSession
from rattail.db.model import Product, PendingProduct, CustomerOrderItem from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem
from rattail.gpc import GPC from rattail.gpc import GPC
from rattail.threads import Thread from rattail.threads import Thread
from rattail.exceptions import LabelPrintingError from rattail.exceptions import LabelPrintingError
@ -1857,7 +1857,8 @@ class ProductView(MasterView):
lookup_fields.append('alt_code') lookup_fields.append('alt_code')
if lookup_fields: if lookup_fields:
product = self.products_handler.locate_product_for_entry( product = self.products_handler.locate_product_for_entry(
session, term, lookup_fields=lookup_fields) session, term, lookup_fields=lookup_fields,
first_if_multiple=True)
if product: if product:
final_results.append(self.search_normalize_result(product)) final_results.append(self.search_normalize_result(product))
@ -2668,6 +2669,78 @@ class PendingProductView(MasterView):
permission=f'{permission_prefix}.ignore_product') permission=f'{permission_prefix}.ignore_product')
class ProductCostView(MasterView):
"""
Master view for Product Costs
"""
model_class = ProductCost
route_prefix = 'product_costs'
url_prefix = '/products/costs'
has_versions = True
grid_columns = [
'_product_key_',
'vendor',
'preference',
'code',
'case_size',
'case_cost',
'pack_size',
'pack_cost',
'unit_cost',
]
def query(self, session):
""" """
query = super().query(session)
model = self.app.model
# always join on Product
return query.join(model.Product)
def configure_grid(self, g):
""" """
super().configure_grid(g)
model = self.app.model
# product key
field = self.get_product_key_field()
g.set_renderer(field, self.render_product_key)
g.set_sorter(field, getattr(model.Product, field))
g.set_sort_defaults(field)
g.set_filter(field, getattr(model.Product, field))
# vendor
g.set_joiner('vendor', lambda q: q.join(model.Vendor))
g.set_sorter('vendor', model.Vendor.name)
g.set_filter('vendor', model.Vendor.name, label="Vendor Name")
def render_product_key(self, cost, field):
""" """
handler = self.app.get_products_handler()
return handler.render_product_key(cost.product)
def configure_form(self, f):
""" """
super().configure_form(f)
# product
f.set_renderer('product', self.render_product)
if 'product_uuid' in f and 'product' in f:
f.remove('product')
f.replace('product_uuid', 'product')
# vendor
f.set_renderer('vendor', self.render_vendor)
if 'vendor_uuid' in f and 'vendor' in f:
f.remove('vendor')
f.replace('vendor_uuid', 'vendor')
# futures
# TODO: should eventually show a subgrid here?
f.remove('futures')
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()
@ -2677,6 +2750,9 @@ def defaults(config, **kwargs):
PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
PendingProductView.defaults(config) PendingProductView.defaults(config)
ProductCostView = kwargs.get('ProductCostView', base['ProductCostView'])
ProductCostView.defaults(config)
def includeme(config): def includeme(config):
defaults(config) defaults(config)

View file

@ -24,6 +24,8 @@
Base class for purchasing batch views Base class for purchasing batch views
""" """
import warnings
from rattail.db.model import PurchaseBatch, PurchaseBatchRow from rattail.db.model import PurchaseBatch, PurchaseBatchRow
import colander import colander
@ -67,6 +69,8 @@ class PurchasingBatchView(BatchMasterView):
'store', 'store',
'buyer', 'buyer',
'vendor', 'vendor',
'description',
'workflow',
'department', 'department',
'purchase', 'purchase',
'vendor_email', 'vendor_email',
@ -158,6 +162,174 @@ class PurchasingBatchView(BatchMasterView):
def batch_mode(self): def batch_mode(self):
raise NotImplementedError("Please define `batch_mode` for your purchasing batch view") raise NotImplementedError("Please define `batch_mode` for your purchasing batch view")
def get_supported_workflows(self):
"""
Return the supported "create batch" workflows.
"""
enum = self.app.enum
if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
return self.batch_handler.supported_ordering_workflows()
elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
return self.batch_handler.supported_receiving_workflows()
elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
return self.batch_handler.supported_costing_workflows()
raise ValueError("unknown batch mode")
def allow_any_vendor(self):
"""
Return boolean indicating whether creating a batch for "any"
vendor is allowed, vs. only supported vendors.
"""
enum = self.app.enum
if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
return self.batch_handler.allow_ordering_any_vendor()
elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
value = self.config.get_bool('rattail.batch.purchase.allow_receiving_any_vendor')
if value is not None:
return value
value = self.config.get_bool('rattail.batch.purchase.supported_vendors_only')
if value is not None:
warnings.warn("setting rattail.batch.purchase.supported_vendors_only is deprecated; "
"please use rattail.batch.purchase.allow_receiving_any_vendor instead",
DeprecationWarning)
# nb. must negate this setting
return not value
return False
raise ValueError("unknown batch mode")
def get_supported_vendors(self):
"""
Return the supported vendors for creating a batch.
"""
return []
def create(self, form=None, **kwargs):
"""
Custom view for creating a new batch. We split the process
into two steps, 1) choose workflow and 2) create batch. This
is because the specific form details for creating a batch will
depend on which "type" of batch creation is to be done, and
it's much easier to keep conditional logic for that in the
server instead of client-side etc.
"""
model = self.app.model
enum = self.app.enum
route_prefix = self.get_route_prefix()
workflows = self.get_supported_workflows()
valid_workflows = [workflow['workflow_key']
for workflow in workflows]
# if user has already identified their desired workflow, then
# we can just farm out to the default logic. we will of
# course configure our form differently, based on workflow,
# but this create() method at least will not need
# customization for that.
if self.request.matched_route.name.endswith('create_workflow'):
redirect = self.redirect(self.request.route_url(f'{route_prefix}.create'))
# however we do have one more thing to check - the workflow
# requested must of course be valid!
workflow_key = self.request.matchdict['workflow_key']
if workflow_key not in valid_workflows:
self.request.session.flash(f"Not a supported workflow: {workflow_key}", 'error')
raise redirect
# also, we require vendor to be correctly identified. if
# someone e.g. navigates to a URL by accident etc. we want
# to gracefully handle and redirect
uuid = self.request.matchdict['vendor_uuid']
vendor = self.Session.get(model.Vendor, uuid)
if not vendor:
self.request.session.flash("Invalid vendor selection. "
"Please choose an existing vendor.",
'warning')
raise redirect
# okay now do the normal thing, per workflow
return super().create(**kwargs)
# on the other hand, if caller provided a form, that means we are in
# the middle of some other custom workflow, e.g. "add child to truck
# dump parent" or some such. in which case we also defer to the normal
# logic, so as to not interfere with that.
if form:
return super().create(form=form, **kwargs)
# okay, at this point we need the user to select a vendor and workflow
self.creating = True
context = {}
# form to accept user choice of vendor/workflow
schema = colander.Schema()
schema.add(colander.SchemaNode(colander.String(), name='vendor'))
schema.add(colander.SchemaNode(colander.String(), name='workflow',
validator=colander.OneOf(valid_workflows)))
factory = self.get_form_factory()
form = factory(schema=schema, request=self.request)
# configure vendor field
vendor_handler = self.app.get_vendor_handler()
if self.allow_any_vendor():
# user may choose *any* available vendor
use_dropdown = vendor_handler.choice_uses_dropdown()
if use_dropdown:
vendors = self.Session.query(model.Vendor)\
.order_by(model.Vendor.id)\
.all()
vendor_values = [(vendor.uuid, f"({vendor.id}) {vendor.name}")
for vendor in vendors]
form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
if len(vendors) == 1:
form.set_default('vendor', vendors[0].uuid)
else:
vendor_display = ""
if self.request.method == 'POST':
if self.request.POST.get('vendor'):
vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
if vendor:
vendor_display = str(vendor)
vendors_url = self.request.route_url('vendors.autocomplete')
form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
field_display=vendor_display, service_url=vendors_url))
else: # only "supported" vendors allowed
vendors = self.get_supported_vendors()
vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
for vendor in vendors]
form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
form.set_validator('vendor', self.valid_vendor_uuid)
# configure workflow field
values = [(workflow['workflow_key'], workflow['display'])
for workflow in workflows]
form.set_widget('workflow',
dfwidget.SelectWidget(values=values))
if len(workflows) == 1:
form.set_default('workflow', workflows[0]['workflow_key'])
form.submit_label = "Continue"
form.cancel_url = self.get_index_url()
# if form validates, that means user has chosen a creation
# type, so we just redirect to the appropriate "new batch of
# type X" page
if form.validate():
workflow_key = form.validated['workflow']
vendor_uuid = form.validated['vendor']
url = self.request.route_url(f'{route_prefix}.create_workflow',
workflow_key=workflow_key,
vendor_uuid=vendor_uuid)
raise self.redirect(url)
context['form'] = form
if hasattr(form, 'make_deform_form'):
context['dform'] = form.make_deform_form()
return self.render_to_response('create', context)
def query(self, session): def query(self, session):
model = self.model model = self.model
return session.query(model.PurchaseBatch)\ return session.query(model.PurchaseBatch)\
@ -226,20 +398,40 @@ class PurchasingBatchView(BatchMasterView):
def configure_form(self, f): def configure_form(self, f):
super().configure_form(f) super().configure_form(f)
model = self.model model = self.app.model
enum = self.app.enum
route_prefix = self.get_route_prefix()
today = self.app.today()
batch = f.model_instance batch = f.model_instance
app = self.get_rattail_app() workflow = self.request.matchdict.get('workflow_key')
today = app.localtime().date() vendor_handler = self.app.get_vendor_handler()
# mode # mode
f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) f.set_enum('mode', enum.PURCHASE_BATCH_MODE)
# workflow
if self.creating:
if workflow:
f.set_widget('workflow', dfwidget.HiddenWidget())
f.set_default('workflow', workflow)
f.set_hidden('workflow')
# nb. show readonly '_workflow'
f.insert_after('workflow', '_workflow')
f.set_readonly('_workflow')
f.set_renderer('_workflow', self.render_workflow)
else:
f.set_readonly('workflow')
f.set_renderer('workflow', self.render_workflow)
else:
f.remove('workflow')
# store # store
single_store = self.rattail_config.single_store() single_store = self.config.single_store()
if self.creating: if self.creating:
f.replace('store', 'store_uuid') f.replace('store', 'store_uuid')
if single_store: if single_store:
store = self.rattail_config.get_store(self.Session()) store = self.config.get_store(self.Session())
f.set_widget('store_uuid', dfwidget.HiddenWidget()) f.set_widget('store_uuid', dfwidget.HiddenWidget())
f.set_default('store_uuid', store.uuid) f.set_default('store_uuid', store.uuid)
f.set_hidden('store_uuid') f.set_hidden('store_uuid')
@ -263,7 +455,6 @@ class PurchasingBatchView(BatchMasterView):
if self.creating: if self.creating:
f.replace('vendor', 'vendor_uuid') f.replace('vendor', 'vendor_uuid')
f.set_label('vendor_uuid', "Vendor") f.set_label('vendor_uuid', "Vendor")
vendor_handler = app.get_vendor_handler()
use_dropdown = vendor_handler.choice_uses_dropdown() use_dropdown = vendor_handler.choice_uses_dropdown()
if use_dropdown: if use_dropdown:
vendors = self.Session.query(model.Vendor)\ vendors = self.Session.query(model.Vendor)\
@ -313,7 +504,7 @@ class PurchasingBatchView(BatchMasterView):
if buyer: if buyer:
buyer_display = str(buyer) buyer_display = str(buyer)
elif self.creating: elif self.creating:
buyer = app.get_employee(self.request.user) buyer = self.app.get_employee(self.request.user)
if buyer: if buyer:
buyer_display = str(buyer) buyer_display = str(buyer)
f.set_default('buyer_uuid', buyer.uuid) f.set_default('buyer_uuid', buyer.uuid)
@ -324,6 +515,30 @@ class PurchasingBatchView(BatchMasterView):
field_display=buyer_display, service_url=buyers_url)) field_display=buyer_display, service_url=buyers_url))
f.set_label('buyer_uuid', "Buyer") f.set_label('buyer_uuid', "Buyer")
# order_file
if self.creating:
f.set_type('order_file', 'file', required=False)
else:
f.set_readonly('order_file')
f.set_renderer('order_file', self.render_downloadable_file)
# order_parser_key
if self.creating:
kwargs = {}
if 'vendor_uuid' in self.request.matchdict:
vendor = self.Session.get(model.Vendor,
self.request.matchdict['vendor_uuid'])
if vendor:
kwargs['vendor'] = vendor
parsers = vendor_handler.get_supported_order_parsers(**kwargs)
parser_values = [(p.key, p.title) for p in parsers]
if len(parsers) == 1:
f.set_default('order_parser_key', parsers[0].key)
f.set_widget('order_parser_key', dfwidget.SelectWidget(values=parser_values))
f.set_label('order_parser_key', "Order Parser")
else:
f.remove_field('order_parser_key')
# invoice_file # invoice_file
if self.creating: if self.creating:
f.set_type('invoice_file', 'file', required=False) f.set_type('invoice_file', 'file', required=False)
@ -341,7 +556,7 @@ class PurchasingBatchView(BatchMasterView):
if vendor: if vendor:
kwargs['vendor'] = vendor kwargs['vendor'] = vendor
parsers = self.handler.get_supported_invoice_parsers(**kwargs) parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs)
parser_values = [(p.key, p.display) for p in parsers] parser_values = [(p.key, p.display) for p in parsers]
if len(parsers) == 1: if len(parsers) == 1:
f.set_default('invoice_parser_key', parsers[0].key) f.set_default('invoice_parser_key', parsers[0].key)
@ -400,6 +615,35 @@ class PurchasingBatchView(BatchMasterView):
'vendor_contact', 'vendor_contact',
'status_code') 'status_code')
# tweak some things if we are in "step 2" of creating new batch
if self.creating and workflow:
# display vendor but do not allow changing
vendor = self.Session.get(model.Vendor, self.request.matchdict['vendor_uuid'])
if not vendor:
raise ValueError(f"vendor not found: {self.request.matchdict['vendor_uuid']}")
f.set_readonly('vendor_uuid')
f.set_default('vendor_uuid', str(vendor))
# cancel should take us back to choosing a workflow
f.cancel_url = self.request.route_url(f'{route_prefix}.create')
def render_workflow(self, batch, field):
key = self.request.matchdict['workflow_key']
info = self.get_workflow_info(key)
if info:
return info['display']
def get_workflow_info(self, key):
enum = self.app.enum
if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
return self.batch_handler.ordering_workflow_info(key)
elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
return self.batch_handler.receiving_workflow_info(key)
elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
return self.batch_handler.costing_workflow_info(key)
raise ValueError("unknown batch mode")
def render_store(self, batch, field): def render_store(self, batch, field):
store = batch.store store = batch.store
if not store: if not store:
@ -515,10 +759,12 @@ class PurchasingBatchView(BatchMasterView):
def get_batch_kwargs(self, batch, **kwargs): def get_batch_kwargs(self, batch, **kwargs):
kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs = super().get_batch_kwargs(batch, **kwargs)
model = self.model model = self.app.model
kwargs['mode'] = self.batch_mode kwargs['mode'] = self.batch_mode
kwargs['workflow'] = self.request.POST['workflow']
kwargs['truck_dump'] = batch.truck_dump kwargs['truck_dump'] = batch.truck_dump
kwargs['order_parser_key'] = batch.order_parser_key
kwargs['invoice_parser_key'] = batch.invoice_parser_key kwargs['invoice_parser_key'] = batch.invoice_parser_key
if batch.store: if batch.store:
@ -536,6 +782,11 @@ class PurchasingBatchView(BatchMasterView):
elif batch.vendor_uuid: elif batch.vendor_uuid:
kwargs['vendor_uuid'] = batch.vendor_uuid kwargs['vendor_uuid'] = batch.vendor_uuid
# must pull vendor from URL if it was not in form data
if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
if 'vendor_uuid' in self.request.matchdict:
kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
if batch.department: if batch.department:
kwargs['department'] = batch.department kwargs['department'] = batch.department
elif batch.department_uuid: elif batch.department_uuid:
@ -919,6 +1170,25 @@ class PurchasingBatchView(BatchMasterView):
# # otherwise just view batch again # # otherwise just view batch again
# return self.get_action_url('view', batch) # return self.get_action_url('view', batch)
@classmethod
def defaults(cls, config):
cls._purchase_batch_defaults(config)
cls._batch_defaults(config)
cls._defaults(config)
@classmethod
def _purchase_batch_defaults(cls, config):
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
permission_prefix = cls.get_permission_prefix()
# new batch using workflow X
config.add_route(f'{route_prefix}.create_workflow',
f'{url_prefix}/new/{{workflow_key}}/{{vendor_uuid}}')
config.add_view(cls, attr='create',
route_name=f'{route_prefix}.create_workflow',
permission=f'{permission_prefix}.create')
class NewProduct(colander.Schema): class NewProduct(colander.Schema):

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.
# #
@ -28,14 +28,10 @@ import os
import json import json
import openpyxl import openpyxl
from sqlalchemy import orm
from rattail.db import model, api
from rattail.core import Object from rattail.core import Object
from rattail.time import localtime
from webhelpers2.html import tags
from tailbone.db import Session
from tailbone.views.purchasing import PurchasingBatchView from tailbone.views.purchasing import PurchasingBatchView
@ -51,6 +47,8 @@ class OrderingBatchView(PurchasingBatchView):
rows_editable = True rows_editable = True
has_worksheet = True has_worksheet = True
default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html' default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html'
downloadable = True
configurable = True
labels = { labels = {
'po_total_calculated': "PO Total", 'po_total_calculated': "PO Total",
@ -59,9 +57,14 @@ class OrderingBatchView(PurchasingBatchView):
form_fields = [ form_fields = [
'id', 'id',
'store', 'store',
'buyer',
'vendor', 'vendor',
'description',
'workflow',
'order_file',
'order_parser_key',
'buyer',
'department', 'department',
'params',
'purchase', 'purchase',
'vendor_email', 'vendor_email',
'vendor_fax', 'vendor_fax',
@ -132,15 +135,26 @@ class OrderingBatchView(PurchasingBatchView):
return self.enum.PURCHASE_BATCH_MODE_ORDERING return self.enum.PURCHASE_BATCH_MODE_ORDERING
def configure_form(self, f): def configure_form(self, f):
super(OrderingBatchView, self).configure_form(f) super().configure_form(f)
batch = f.model_instance batch = f.model_instance
workflow = self.request.matchdict.get('workflow_key')
# purchase # purchase
if self.creating or not batch.executed or not batch.purchase: if self.creating or not batch.executed or not batch.purchase:
f.remove_field('purchase') f.remove_field('purchase')
# now that all fields are setup, some final tweaks based on workflow
if self.creating and workflow:
if workflow == 'from_scratch':
f.remove('order_file',
'order_parser_key')
elif workflow == 'from_file':
f.set_required('order_file')
def get_batch_kwargs(self, batch, **kwargs): def get_batch_kwargs(self, batch, **kwargs):
kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs) kwargs = super().get_batch_kwargs(batch, **kwargs)
kwargs['ship_method'] = batch.ship_method kwargs['ship_method'] = batch.ship_method
kwargs['notes_to_vendor'] = batch.notes_to_vendor kwargs['notes_to_vendor'] = batch.notes_to_vendor
return kwargs return kwargs
@ -155,7 +169,7 @@ class OrderingBatchView(PurchasingBatchView):
* ``cases_ordered`` * ``cases_ordered``
* ``units_ordered`` * ``units_ordered``
""" """
super(OrderingBatchView, self).configure_row_form(f) super().configure_row_form(f)
# when editing, only certain fields should allow changes # when editing, only certain fields should allow changes
if self.editing: if self.editing:
@ -308,7 +322,7 @@ class OrderingBatchView(PurchasingBatchView):
title = self.get_instance_title(batch) title = self.get_instance_title(batch)
order_date = batch.date_ordered order_date = batch.date_ordered
if not order_date: if not order_date:
order_date = localtime(self.rattail_config).date() order_date = self.app.today()
return self.render_to_response('worksheet', { return self.render_to_response('worksheet', {
'batch': batch, 'batch': batch,
@ -369,6 +383,7 @@ class OrderingBatchView(PurchasingBatchView):
of being updated. If a matching row is not found, it will not be of being updated. If a matching row is not found, it will not be
created. created.
""" """
model = self.app.model
batch = self.get_instance() batch = self.get_instance()
try: try:
@ -478,13 +493,75 @@ class OrderingBatchView(PurchasingBatchView):
return self.file_response(path) return self.file_response(path)
def get_execute_success_url(self, batch, result, **kwargs): def get_execute_success_url(self, batch, result, **kwargs):
model = self.app.model
if isinstance(result, model.Purchase): if isinstance(result, model.Purchase):
return self.request.route_url('purchases.view', uuid=result.uuid) return self.request.route_url('purchases.view', uuid=result.uuid)
return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs) return super().get_execute_success_url(batch, result, **kwargs)
def configure_get_simple_settings(self):
return [
# workflows
{'section': 'rattail.batch',
'option': 'purchase.allow_ordering_from_scratch',
'type': bool,
'default': True},
{'section': 'rattail.batch',
'option': 'purchase.allow_ordering_from_file',
'type': bool,
'default': True},
# vendors
{'section': 'rattail.batch',
'option': 'purchase.allow_ordering_any_vendor',
'type': bool,
'default': True,
},
]
def configure_get_context(self):
context = super().configure_get_context()
vendor_handler = self.app.get_vendor_handler()
Parsers = vendor_handler.get_all_order_parsers()
Supported = vendor_handler.get_supported_order_parsers()
context['order_parsers'] = Parsers
context['order_parsers_data'] = dict([(Parser.key, Parser in Supported)
for Parser in Parsers])
return context
def configure_gather_settings(self, data):
settings = super().configure_gather_settings(data)
vendor_handler = self.app.get_vendor_handler()
supported = []
for Parser in vendor_handler.get_all_order_parsers():
name = f'order_parser_{Parser.key}'
if data.get(name) == 'true':
supported.append(Parser.key)
settings.append({'name': 'rattail.vendors.supported_order_parsers',
'value': ', '.join(supported)})
return settings
def configure_remove_settings(self):
super().configure_remove_settings()
names = [
'rattail.vendors.supported_order_parsers',
]
# nb. using thread-local session here; we do not use
# self.Session b/c it may not point to Rattail
session = Session()
for name in names:
self.app.delete_setting(session, name)
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._ordering_defaults(config) cls._ordering_defaults(config)
cls._purchase_batch_defaults(config)
cls._batch_defaults(config) cls._batch_defaults(config)
cls._defaults(config) cls._defaults(config)

View file

@ -108,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView):
'store', 'store',
'vendor', 'vendor',
'description', 'description',
'receiving_workflow', 'workflow',
'truck_dump', 'truck_dump',
'truck_dump_children_first', 'truck_dump_children_first',
'truck_dump_children', 'truck_dump_children',
@ -235,135 +235,18 @@ class ReceivingBatchView(PurchasingBatchView):
if not self.handler.allow_truck_dump_receiving(): if not self.handler.allow_truck_dump_receiving():
g.remove('truck_dump') g.remove('truck_dump')
def create(self, form=None, **kwargs): def get_supported_vendors(self):
""" """ """
Custom view for creating a new receiving batch. We split the process vendor_handler = self.app.get_vendor_handler()
into two steps, 1) choose and 2) create. This is because the specific vendors = {}
form details for creating a batch will depend on which "type" of batch for parser in self.batch_handler.get_supported_invoice_parsers():
creation is to be done, and it's much easier to keep conditional logic if parser.vendor_key:
for that in the server instead of client-side etc. vendor = vendor_handler.get_vendor(self.Session(),
parser.vendor_key)
See also if vendor:
:meth:`tailbone.views.purchasing.costing:CostingBatchView.create()` vendors[vendor.uuid] = vendor
which uses similar logic. vendors = sorted(vendors.values(), key=lambda v: v.name)
""" return vendors
model = self.model
route_prefix = self.get_route_prefix()
workflows = self.handler.supported_receiving_workflows()
valid_workflows = [workflow['workflow_key']
for workflow in workflows]
# if user has already identified their desired workflow, then we can
# just farm out to the default logic. we will of course configure our
# form differently, based on workflow, but this create() method at
# least will not need customization for that.
if self.request.matched_route.name.endswith('create_workflow'):
redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix)))
# however we do have one more thing to check - the workflow
# requested must of course be valid!
workflow_key = self.request.matchdict['workflow_key']
if workflow_key not in valid_workflows:
self.request.session.flash(
"Not a supported workflow: {}".format(workflow_key),
'error')
raise redirect
# also, we require vendor to be correctly identified. if
# someone e.g. navigates to a URL by accident etc. we want
# to gracefully handle and redirect
uuid = self.request.matchdict['vendor_uuid']
vendor = self.Session.get(model.Vendor, uuid)
if not vendor:
self.request.session.flash("Invalid vendor selection. "
"Please choose an existing vendor.",
'warning')
raise redirect
# okay now do the normal thing, per workflow
return super().create(**kwargs)
# on the other hand, if caller provided a form, that means we are in
# the middle of some other custom workflow, e.g. "add child to truck
# dump parent" or some such. in which case we also defer to the normal
# logic, so as to not interfere with that.
if form:
return super().create(form=form, **kwargs)
# okay, at this point we need the user to select a vendor and workflow
self.creating = True
context = {}
# form to accept user choice of vendor/workflow
schema = NewReceivingBatch().bind(valid_workflows=valid_workflows)
form = forms.Form(schema=schema, request=self.request)
# configure vendor field
app = self.get_rattail_app()
vendor_handler = app.get_vendor_handler()
if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'):
# only show vendors for which we have dedicated invoice parsers
vendors = {}
for parser in self.batch_handler.get_supported_invoice_parsers():
if parser.vendor_key:
vendor = vendor_handler.get_vendor(self.Session(),
parser.vendor_key)
if vendor:
vendors[vendor.uuid] = vendor
vendors = sorted(vendors.values(), key=lambda v: v.name)
vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
for vendor in vendors]
form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
else:
# user may choose *any* available vendor
use_dropdown = vendor_handler.choice_uses_dropdown()
if use_dropdown:
vendors = self.Session.query(model.Vendor)\
.order_by(model.Vendor.id)\
.all()
vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name))
for vendor in vendors]
form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
if len(vendors) == 1:
form.set_default('vendor', vendors[0].uuid)
else:
vendor_display = ""
if self.request.method == 'POST':
if self.request.POST.get('vendor'):
vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
if vendor:
vendor_display = str(vendor)
vendors_url = self.request.route_url('vendors.autocomplete')
form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
field_display=vendor_display, service_url=vendors_url))
form.set_validator('vendor', self.valid_vendor_uuid)
# configure workflow field
values = [(workflow['workflow_key'], workflow['display'])
for workflow in workflows]
form.set_widget('workflow',
dfwidget.SelectWidget(values=values))
if len(workflows) == 1:
form.set_default('workflow', workflows[0]['workflow_key'])
form.submit_label = "Continue"
form.cancel_url = self.get_index_url()
# if form validates, that means user has chosen a creation type, so we
# just redirect to the appropriate "new batch of type X" page
if form.validate():
workflow_key = form.validated['workflow']
vendor_uuid = form.validated['vendor']
url = self.request.route_url('{}.create_workflow'.format(route_prefix),
workflow_key=workflow_key,
vendor_uuid=vendor_uuid)
raise self.redirect(url)
context['form'] = form
if hasattr(form, 'make_deform_form'):
context['dform'] = form.make_deform_form()
return self.render_to_response('create', context)
def row_deletable(self, row): def row_deletable(self, row):
@ -404,13 +287,7 @@ class ReceivingBatchView(PurchasingBatchView):
# cancel should take us back to choosing a workflow # cancel should take us back to choosing a workflow
f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) f.cancel_url = self.request.route_url('{}.create'.format(route_prefix))
# receiving_workflow # TODO: remove this
if self.creating and workflow:
f.set_readonly('receiving_workflow')
f.set_renderer('receiving_workflow', self.render_receiving_workflow)
else:
f.remove('receiving_workflow')
# batch_type # batch_type
if self.creating: if self.creating:
f.set_widget('batch_type', dfwidget.HiddenWidget()) f.set_widget('batch_type', dfwidget.HiddenWidget())
@ -525,7 +402,7 @@ class ReceivingBatchView(PurchasingBatchView):
# multiple invoice files (if applicable) # multiple invoice files (if applicable)
if (not self.creating if (not self.creating
and batch.get_param('receiving_workflow') == 'from_multi_invoice'): and batch.get_param('workflow') == 'from_multi_invoice'):
if 'invoice_files' not in f: if 'invoice_files' not in f:
f.insert_before('invoice_file', 'invoice_files') f.insert_before('invoice_file', 'invoice_files')
@ -624,12 +501,6 @@ class ReceivingBatchView(PurchasingBatchView):
items.append(HTML.tag('li', c=[link])) items.append(HTML.tag('li', c=[link]))
return HTML.tag('ul', c=items) return HTML.tag('ul', c=items)
def render_receiving_workflow(self, batch, field):
key = self.request.matchdict['workflow_key']
info = self.handler.receiving_workflow_info(key)
if info:
return info['display']
def get_visible_params(self, batch): def get_visible_params(self, batch):
params = super().get_visible_params(batch) params = super().get_visible_params(batch)
@ -654,42 +525,40 @@ class ReceivingBatchView(PurchasingBatchView):
def get_batch_kwargs(self, batch, **kwargs): def get_batch_kwargs(self, batch, **kwargs):
kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs = super().get_batch_kwargs(batch, **kwargs)
batch_type = self.request.POST['batch_type']
# must pull vendor from URL if it was not in form data # must pull vendor from URL if it was not in form data
if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
if 'vendor_uuid' in self.request.matchdict: if 'vendor_uuid' in self.request.matchdict:
kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
# TODO: ugh should just have workflow and no batch_type workflow = kwargs['workflow']
kwargs['receiving_workflow'] = batch_type if workflow == 'from_scratch':
if batch_type == 'from_scratch':
kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch', None)
kwargs.pop('truck_dump_batch_uuid', None) kwargs.pop('truck_dump_batch_uuid', None)
elif batch_type == 'from_invoice': elif workflow == 'from_invoice':
pass pass
elif batch_type == 'from_multi_invoice': elif workflow == 'from_multi_invoice':
pass pass
elif batch_type == 'from_po': elif workflow == 'from_po':
# TODO: how to best handle this field? this doesn't seem flexible # TODO: how to best handle this field? this doesn't seem flexible
kwargs['purchase_key'] = batch.purchase_uuid kwargs['purchase_key'] = batch.purchase_uuid
elif batch_type == 'from_po_with_invoice': elif workflow == 'from_po_with_invoice':
# TODO: how to best handle this field? this doesn't seem flexible # TODO: how to best handle this field? this doesn't seem flexible
kwargs['purchase_key'] = batch.purchase_uuid kwargs['purchase_key'] = batch.purchase_uuid
elif batch_type == 'truck_dump_children_first': elif workflow == 'truck_dump_children_first':
kwargs['truck_dump'] = True kwargs['truck_dump'] = True
kwargs['truck_dump_children_first'] = True kwargs['truck_dump_children_first'] = True
kwargs['order_quantities_known'] = True kwargs['order_quantities_known'] = True
# TODO: this makes sense in some cases, but all? # TODO: this makes sense in some cases, but all?
# (should just omit that field when not relevant) # (should just omit that field when not relevant)
kwargs['date_ordered'] = None kwargs['date_ordered'] = None
elif batch_type == 'truck_dump_children_last': elif workflow == 'truck_dump_children_last':
kwargs['truck_dump'] = True kwargs['truck_dump'] = True
kwargs['truck_dump_ready'] = True kwargs['truck_dump_ready'] = True
# TODO: this makes sense in some cases, but all? # TODO: this makes sense in some cases, but all?
# (should just omit that field when not relevant) # (should just omit that field when not relevant)
kwargs['date_ordered'] = None kwargs['date_ordered'] = None
elif batch_type.startswith('truck_dump_child'): elif workflow.startswith('truck_dump_child'):
truck_dump = self.get_instance() truck_dump = self.get_instance()
kwargs['store'] = truck_dump.store kwargs['store'] = truck_dump.store
kwargs['vendor'] = truck_dump.vendor kwargs['vendor'] = truck_dump.vendor
@ -1986,6 +1855,12 @@ class ReceivingBatchView(PurchasingBatchView):
'type': bool}, 'type': bool},
# vendors # vendors
{'section': 'rattail.batch',
'option': 'purchase.allow_receiving_any_vendor',
'type': bool},
# TODO: deprecated; can remove this once all live config
# is updated. but for now it remains so this setting is
# auto-deleted
{'section': 'rattail.batch', {'section': 'rattail.batch',
'option': 'purchase.supported_vendors_only', 'option': 'purchase.supported_vendors_only',
'type': bool}, 'type': bool},
@ -2036,6 +1911,7 @@ class ReceivingBatchView(PurchasingBatchView):
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._receiving_defaults(config) cls._receiving_defaults(config)
cls._purchase_batch_defaults(config)
cls._batch_defaults(config) cls._batch_defaults(config)
cls._defaults(config) cls._defaults(config)
@ -2043,17 +1919,11 @@ class ReceivingBatchView(PurchasingBatchView):
def _receiving_defaults(cls, config): def _receiving_defaults(cls, config):
rattail_config = config.registry.settings.get('rattail_config') rattail_config = config.registry.settings.get('rattail_config')
route_prefix = cls.get_route_prefix() route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
instance_url_prefix = cls.get_instance_url_prefix() instance_url_prefix = cls.get_instance_url_prefix()
model_key = cls.get_model_key() model_key = cls.get_model_key()
model_title = cls.get_model_title() model_title = cls.get_model_title()
permission_prefix = cls.get_permission_prefix() permission_prefix = cls.get_permission_prefix()
# new receiving batch using workflow X
config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix))
config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix),
permission='{}.create'.format(permission_prefix))
# row-level receiving # row-level receiving
config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix)) config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix))
config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix), config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
@ -2106,33 +1976,6 @@ class ReceivingBatchView(PurchasingBatchView):
permission='{}.auto_receive'.format(permission_prefix)) permission='{}.auto_receive'.format(permission_prefix))
@colander.deferred
def valid_workflow(node, kw):
"""
Deferred validator for ``workflow`` field, for new batches.
"""
valid_workflows = kw['valid_workflows']
def validate(node, value):
# we just need to provide possible values, and let stock validator
# handle the rest
oneof = colander.OneOf(valid_workflows)
return oneof(node, value)
return validate
class NewReceivingBatch(colander.Schema):
"""
Schema for choosing which "type" of new receiving batch should be created.
"""
vendor = colander.SchemaNode(colander.String(),
label="Vendor")
workflow = colander.SchemaNode(colander.String(),
validator=valid_workflow)
class ReceiveRowForm(colander.MappingSchema): class ReceiveRowForm(colander.MappingSchema):
mode = colander.SchemaNode(colander.String(), mode = colander.SchemaNode(colander.String(),

View file

@ -25,11 +25,7 @@ Settings Views
""" """
import json import json
import os
import re import re
import subprocess
import sys
from collections import OrderedDict
import colander import colander
@ -37,194 +33,159 @@ from rattail.db.model import Setting
from rattail.settings import Setting as AppSetting from rattail.settings import Setting as AppSetting
from rattail.util import import_module_path from rattail.util import import_module_path
from tailbone import forms from tailbone import forms, grids
from tailbone.db import Session from tailbone.db import Session
from tailbone.views import MasterView, View from tailbone.views import MasterView, View
from wuttaweb.util import get_libver, get_liburl from wuttaweb.util import get_libver, get_liburl
from wuttaweb.views.settings import AppInfoView as WuttaAppInfoView
class AppInfoView(MasterView): class AppInfoView(WuttaAppInfoView):
""" """ """
Master view for the overall app, to show/edit config etc. Session = Session
""" weblib_config_prefix = 'tailbone'
route_prefix = 'appinfo'
model_key = 'UNUSED'
model_title = "UNUSED"
model_title_plural = "App Details"
creatable = False
viewable = False
editable = False
deletable = False
filterable = False
pageable = False
configurable = True
grid_columns = [ # TODO: for now we override to get tailbone searchable grid
'name', def make_grid(self, **kwargs):
'version', """ """
'editable_project_location', return grids.Grid(self.request, **kwargs)
]
def get_index_title(self):
app = self.get_rattail_app()
return "{} for {}".format(self.get_model_title_plural(),
app.get_title())
def get_data(self, session=None):
pip = os.path.join(sys.prefix, 'bin', 'pip')
output = subprocess.check_output([pip, 'list', '--format=json'])
data = json.loads(output.decode('utf_8').strip())
for pkg in data:
pkg.setdefault('editable_project_location', '')
return data
def configure_grid(self, g): def configure_grid(self, g):
""" """
super().configure_grid(g) super().configure_grid(g)
# sort on frontend
g.sort_on_backend = False
g.sort_multiple = False
g.set_sort_defaults('name')
# name # name
g.set_searchable('name') g.set_searchable('name')
# editable_project_location # editable_project_location
g.set_searchable('editable_project_location') g.set_searchable('editable_project_location')
def template_kwargs_index(self, **kwargs):
kwargs = super().template_kwargs_index(**kwargs)
kwargs['configure_button_title'] = "Configure App"
return kwargs
def get_weblibs(self):
""" """
return OrderedDict([
('vue', "Vue"),
('vue_resource', "vue-resource"),
('buefy', "Buefy"),
('buefy.css', "Buefy CSS"),
('fontawesome', "FontAwesome"),
('bb_vue', "(BB) vue"),
('bb_oruga', "(BB) @oruga-ui/oruga-next"),
('bb_oruga_bulma', "(BB) @oruga-ui/theme-bulma (JS)"),
('bb_oruga_bulma_css', "(BB) @oruga-ui/theme-bulma (CSS)"),
('bb_fontawesome_svg_core', "(BB) @fortawesome/fontawesome-svg-core"),
('bb_free_solid_svg_icons', "(BB) @fortawesome/free-solid-svg-icons"),
('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"),
])
def configure_get_context(self, **kwargs): def configure_get_context(self, **kwargs):
""" """ """ """
context = super().configure_get_context(**kwargs) context = super().configure_get_context(**kwargs)
simple_settings = context['simple_settings'] simple_settings = context['simple_settings']
weblibs = self.get_weblibs() weblibs = context['weblibs']
for key in weblibs: for weblib in weblibs:
title = weblibs[key] key = weblib['key']
weblibs[key] = {
'key': key,
'title': title,
# nb. these values are exactly as configured, and are
# used for editing the settings
'configured_version': get_libver(self.request, key,
prefix='tailbone',
configured_only=True),
'configured_url': get_liburl(self.request, key,
prefix='tailbone',
configured_only=True),
# these are for informational purposes only
'default_version': get_libver(self.request, key,
prefix='tailbone',
default_only=True),
'live_url': get_liburl(self.request, key,
prefix='tailbone'),
}
# TODO: this is only needed to migrate legacy settings to # TODO: this is only needed to migrate legacy settings to
# use the newer wutaweb setting names # use the newer wuttaweb setting names
url = simple_settings[f'wuttaweb.liburl.{key}'] url = simple_settings[f'wuttaweb.liburl.{key}']
if not url and weblibs[key]['configured_url']: if not url and weblib['configured_url']:
simple_settings[f'wuttaweb.liburl.{key}'] = weblibs[key]['configured_url'] simple_settings[f'wuttaweb.liburl.{key}'] = weblib['configured_url']
context['weblibs'] = list(weblibs.values())
return context return context
# nb. these email settings require special handling below
configure_profile_key_mismatches = [
'default.subject',
'default.to',
'default.cc',
'default.bcc',
'feedback.subject',
'feedback.to',
]
def configure_get_simple_settings(self): def configure_get_simple_settings(self):
""" """ """ """
simple_settings = [ simple_settings = super().configure_get_simple_settings()
# basics # TODO:
{'section': 'rattail', # there are several email config keys which differ between
'option': 'app_title'}, # wuttjamaican and rattail. basically all of the "profile" keys
{'section': 'rattail', # have a different prefix.
'option': 'node_type'},
{'section': 'rattail',
'option': 'node_title'},
{'section': 'rattail',
'option': 'production',
'type': bool},
{'section': 'rattail',
'option': 'running_from_source',
'type': bool},
{'section': 'rattail',
'option': 'running_from_source.rootpkg'},
# display # after wuttaweb has declared its settings, we examine each and
{'section': 'tailbone', # overwrite the value if one is defined with rattail config key.
'option': 'background_color'}, # (nb. this happens even if wuttjamaican key has a value!)
# grids # note that we *do* declare the profile mismatch keys for
{'section': 'tailbone', # rattail, as part of simple settings. this ensures the
'option': 'grid.default_pagesize', # parent logic will always remove them when saving. however
# TODO: seems like should enforce this, but validation is # we must also include them in gather_settings() to ensure
# not setup yet # they are saved to match wuttjamaican values.
# 'type': int
},
# nb. these are no longer used (deprecated), but we keep # there are also a couple of flags where rattail's default is the
# them defined here so the tool auto-deletes them # opposite of wuttjamaican. so we overwrite those too as needed.
{'section': 'tailbone',
'option': 'buefy_version'},
{'section': 'tailbone',
'option': 'vue_version'},
] for setting in simple_settings:
def getval(key): # nb. the update home page redirect setting is off by
return self.config.get(f'tailbone.{key}') # default for wuttaweb, but on for tailbone
if setting['name'] == 'wuttaweb.home_redirect_to_login':
value = self.config.get_bool('wuttaweb.home_redirect_to_login')
if value is None:
value = self.config.get_bool('tailbone.login_is_home', default=True)
setting['value'] = value
weblibs = self.get_weblibs() # nb. sending email is off by default for wuttjamaican,
for key, title in weblibs.items(): # but on for rattail
elif setting['name'] == 'rattail.mail.send_emails':
value = self.config.get_bool('rattail.mail.send_emails', default=True)
setting['value'] = value
simple_settings.append({ # nb. this one is even more special, key is entirely different
'section': 'wuttaweb', elif setting['name'] == 'rattail.email.default.sender':
'option': f"libver.{key}", value = self.config.get('rattail.email.default.sender')
'default': getval(f"libver.{key}"), if value is None:
}) value = self.config.get('rattail.mail.default.from')
simple_settings.append({ setting['value'] = value
'section': 'wuttaweb',
'option': f"liburl.{key}",
'default': getval(f"liburl.{key}"),
})
# nb. these are no longer used (deprecated), but we keep else:
# them defined here so the tool auto-deletes them
simple_settings.append({ # nb. fetch alternate value for profile key mismatch
'section': 'tailbone', for key in self.configure_profile_key_mismatches:
'option': f"libver.{key}", if setting['name'] == f'rattail.email.{key}':
}) value = self.config.get(f'rattail.email.{key}')
simple_settings.append({ if value is None:
'section': 'tailbone', value = self.config.get(f'rattail.mail.{key}')
'option': f"liburl.{key}", setting['value'] = value
}) break
# nb. these are no longer used (deprecated), but we keep
# them defined here so the tool auto-deletes them
simple_settings.extend([
{'name': 'tailbone.login_is_home'},
{'name': 'tailbone.buefy_version'},
{'name': 'tailbone.vue_version'},
])
simple_settings.append({'name': 'rattail.mail.default.from'})
for key in self.configure_profile_key_mismatches:
simple_settings.append({'name': f'rattail.mail.{key}'})
for key in self.get_weblibs():
simple_settings.extend([
{'name': f'tailbone.libver.{key}'},
{'name': f'tailbone.liburl.{key}'},
])
return simple_settings return simple_settings
def configure_gather_settings(self, data, simple_settings=None):
""" """
settings = super().configure_gather_settings(data, simple_settings=simple_settings)
# nb. must add legacy rattail profile settings to match new ones
for setting in list(settings):
if setting['name'] == 'rattail.email.default.sender':
value = setting['value']
settings.append({'name': 'rattail.mail.default.from',
'value': value})
else:
for key in self.configure_profile_key_mismatches:
if setting['name'] == f'rattail.email.{key}':
value = setting['value']
settings.append({'name': f'rattail.mail.{key}',
'value': value})
break
return settings
class SettingView(MasterView): class SettingView(MasterView):
""" """

View file

@ -348,56 +348,27 @@ class UpgradeView(MasterView):
commit_hash_pattern = re.compile(r'^.{40}$') commit_hash_pattern = re.compile(r'^.{40}$')
def get_changelog_projects(self): def get_changelog_projects(self):
projects = { project_map = {
'rattail': { 'onager': 'onager',
'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10', 'pyCOREPOS': 'pycorepos',
'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst', 'rattail': 'rattail',
}, 'rattail_corepos': 'rattail-corepos',
'Tailbone': { 'rattail-onager': 'rattail-onager',
'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10', 'rattail_tempmon': 'rattail-tempmon',
'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst', 'rattail_woocommerce': 'rattail-woocommerce',
}, 'Tailbone': 'tailbone',
'pyCOREPOS': { 'tailbone_corepos': 'tailbone-corepos',
'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10', 'tailbone-onager': 'tailbone-onager',
'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst', 'tailbone_theo': 'theo',
}, 'tailbone_woocommerce': 'tailbone-woocommerce',
'rattail_corepos': {
'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10',
'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/v{new_version}/CHANGES.rst',
},
'tailbone_corepos': {
'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10',
'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/v{new_version}/CHANGES.rst',
},
'onager': {
'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10',
'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/v{new_version}/CHANGES.rst',
},
'rattail-onager': {
'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10',
'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/v{new_version}/CHANGELOG.md',
},
'rattail_tempmon': {
'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10',
'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/v{new_version}/CHANGES.rst',
},
'tailbone-onager': {
'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10',
'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/v{new_version}/CHANGELOG.md',
},
'rattail_woocommerce': {
'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10',
'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/v{new_version}/CHANGES.rst',
},
'tailbone_woocommerce': {
'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10',
'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/v{new_version}/CHANGES.rst',
},
'tailbone_theo': {
'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10',
'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/v{new_version}/CHANGES.rst',
},
} }
projects = {}
for name, repo in project_map.items():
projects[name] = {
'commit_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/compare/{{old_version}}...{{new_version}}',
'release_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/src/tag/v{{new_version}}/CHANGELOG.md',
}
return projects return projects
def get_changelog_url(self, project, old_version, new_version): def get_changelog_url(self, project, old_version, new_version):

View file

@ -801,4 +801,8 @@ def defaults(config, **kwargs):
def includeme(config): def includeme(config):
defaults(config) wutta_config = config.registry.settings['wutta_config']
if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False):
config.include('tailbone.views.wutta.users')
else:
defaults(config)

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.
# #
@ -83,12 +83,12 @@ class WorkOrderView(MasterView):
] ]
def __init__(self, request): def __init__(self, request):
super(WorkOrderView, self).__init__(request) super().__init__(request)
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 configure_grid(self, g): def configure_grid(self, g):
super(WorkOrderView, self).configure_grid(g) super().configure_grid(g)
model = self.model model = self.model
# customer # customer
@ -113,7 +113,7 @@ class WorkOrderView(MasterView):
return 'warning' return 'warning'
def configure_form(self, f): def configure_form(self, f):
super(WorkOrderView, self).configure_form(f) super().configure_form(f)
model = self.model model = self.model
SelectWidget = forms.widgets.JQuerySelectWidget SelectWidget = forms.widgets.JQuerySelectWidget
@ -208,7 +208,7 @@ class WorkOrderView(MasterView):
return event.workorder return event.workorder
def configure_row_grid(self, g): def configure_row_grid(self, g):
super(WorkOrderView, self).configure_row_grid(g) super().configure_row_grid(g)
g.set_enum('type_code', self.enum.WORKORDER_EVENT) g.set_enum('type_code', self.enum.WORKORDER_EVENT)
g.set_sort_defaults('occurred') g.set_sort_defaults('occurred')
@ -353,7 +353,7 @@ class WorkOrderView(MasterView):
class StatusFilter(grids.filters.AlchemyIntegerFilter): class StatusFilter(grids.filters.AlchemyIntegerFilter):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(StatusFilter, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
from drild import enum from drild import enum
@ -369,14 +369,14 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter):
@property @property
def verb_labels(self): def verb_labels(self):
labels = dict(super(StatusFilter, self).verb_labels) labels = dict(super().verb_labels)
labels['is_active'] = "Is Active" labels['is_active'] = "Is Active"
labels['not_active'] = "Is Not Active" labels['not_active'] = "Is Not Active"
return labels return labels
@property @property
def valueless_verbs(self): def valueless_verbs(self):
verbs = list(super(StatusFilter, self).valueless_verbs) verbs = list(super().valueless_verbs)
verbs.extend([ verbs.extend([
'is_active', 'is_active',
'not_active', 'not_active',
@ -385,7 +385,11 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter):
@property @property
def default_verbs(self): def default_verbs(self):
verbs = list(super(StatusFilter, self).default_verbs) verbs = super().default_verbs
if callable(verbs):
verbs = verbs()
verbs = list(verbs or [])
verbs.insert(0, 'is_active') verbs.insert(0, 'is_active')
verbs.insert(1, 'not_active') verbs.insert(1, 'not_active')
return verbs return verbs

View file

@ -32,6 +32,7 @@ from wuttaweb.views import people as wutta
from tailbone.views import people as tailbone from tailbone.views import people as tailbone
from tailbone.db import Session from tailbone.db import Session
from rattail.db.model import Person from rattail.db.model import Person
from tailbone.grids import Grid
class PersonView(wutta.PersonView): class PersonView(wutta.PersonView):
@ -44,7 +45,6 @@ class PersonView(wutta.PersonView):
""" """
model_class = Person model_class = Person
Session = Session Session = Session
sort_defaults = 'display_name'
labels = { labels = {
'display_name': "Full Name", 'display_name': "Full Name",
@ -59,6 +59,11 @@ class PersonView(wutta.PersonView):
'merge_requested', 'merge_requested',
] ]
filter_defaults = {
'display_name': {'active': True, 'verb': 'contains'},
}
sort_defaults = 'display_name'
form_fields = [ form_fields = [
'first_name', 'first_name',
'middle_name', 'middle_name',
@ -74,6 +79,11 @@ class PersonView(wutta.PersonView):
# CRUD methods # CRUD methods
############################## ##############################
# TODO: must use older grid for now, to render filters correctly
def make_grid(self, **kwargs):
""" """
return Grid(self.request, **kwargs)
def configure_grid(self, g): def configure_grid(self, g):
""" """ """ """
super().configure_grid(g) super().configure_grid(g)

View file

@ -0,0 +1,57 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2024 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
User Views
"""
from wuttaweb.views import users as wutta
from tailbone.views import users as tailbone
from tailbone.db import Session
from rattail.db.model import User
from tailbone.grids import Grid
class UserView(wutta.UserView):
"""
This is the first attempt at blending newer Wutta views with
legacy Tailbone config.
So, this is a Wutta-based view but it should be included by a
Tailbone app configurator.
"""
model_class = User
Session = Session
# TODO: must use older grid for now, to render filters correctly
def make_grid(self, **kwargs):
""" """
return Grid(self.request, **kwargs)
def defaults(config, **kwargs):
kwargs.setdefault('UserView', UserView)
tailbone.defaults(config, **kwargs)
def includeme(config):
defaults(config)

View file

@ -57,6 +57,12 @@ class TestGrid(WebTestCase):
grid = self.make_grid(default_page=42) grid = self.make_grid(default_page=42)
self.assertEqual(grid.page, 42) self.assertEqual(grid.page, 42)
# searchable
grid = self.make_grid()
self.assertEqual(grid.searchable_columns, set())
grid = self.make_grid(searchable={'foo': True})
self.assertEqual(grid.searchable_columns, {'foo'})
def test_vue_tagname(self): def test_vue_tagname(self):
# default # default
@ -129,7 +135,7 @@ class TestGrid(WebTestCase):
def test_set_label(self): def test_set_label(self):
model = self.app.model model = self.app.model
grid = self.make_grid(model_class=model.Setting) grid = self.make_grid(model_class=model.Setting, filterable=True)
self.assertEqual(grid.labels, {}) self.assertEqual(grid.labels, {})
# basic # basic

View file

@ -5,12 +5,9 @@ from unittest import TestCase
from pyramid.config import Configurator from pyramid.config import Configurator
from wuttjamaican.testing import FileConfigTestCase
from rattail.exceptions import ConfigurationError from rattail.exceptions import ConfigurationError
from rattail.config import RattailConfig from rattail.testing import DataTestCase
from tailbone import app as mod from tailbone import app as mod
from tests.util import DataTestCase
class TestRattailConfig(TestCase): class TestRattailConfig(TestCase):
@ -30,7 +27,7 @@ class TestRattailConfig(TestCase):
class TestMakePyramidConfig(DataTestCase): class TestMakePyramidConfig(DataTestCase):
def make_config(self): def make_config(self, **kwargs):
myconf = self.write_file('web.conf', """ myconf = self.write_file('web.conf', """
[rattail.db] [rattail.db]
default.url = sqlite:// default.url = sqlite://

View file

@ -38,7 +38,7 @@ class TestPersonView(WebTestCase):
def test_configure_form(self): def test_configure_form(self):
model = self.app.model model = self.app.model
barney = model.User(username='barney') barney = model.Person(display_name="Barney Rubble")
self.session.add(barney) self.session.add(barney)
self.session.commit() self.session.commit()
view = self.make_view() view = self.make_view()