diff --git a/CHANGELOG.md b/CHANGELOG.md index c974b3a6..412e6e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,294 +5,6 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## v0.22.7 (2025-02-19) - -### Fix - -- stop using old config for logo image url on login page -- fix warning msg for deprecated Grid param - -## v0.22.6 (2025-02-01) - -### Fix - -- register vue3 form component for products -> make batch - -## v0.22.5 (2024-12-16) - -### Fix - -- whoops this is latest rattail -- require newer rattail lib -- require newer wuttaweb -- let caller request safe HTML literal for rendered grid table - -## v0.22.4 (2024-11-22) - -### Fix - -- avoid error in product search for duplicated key -- use vmodel for confirm password widget input - -## v0.22.3 (2024-11-19) - -### Fix - -- avoid error for trainwreck query when not a customer - -## v0.22.2 (2024-11-18) - -### Fix - -- use local/custom enum for continuum operations -- add basic master view for Product Costs -- show continuum operation type when viewing version history -- always define `app` attr for ViewSupplement -- avoid deprecated import - -## v0.22.1 (2024-11-02) - -### Fix - -- fix submit button for running problem report -- avoid deprecated grid method - -## v0.22.0 (2024-10-22) - -### Feat - -- add support for new ordering batch from parsed file - -### Fix - -- avoid deprecated method to suggest username - -## v0.21.11 (2024-10-03) - -### Fix - -- custom method for adding grid action -- become/stop root should redirect to previous url - -## v0.21.10 (2024-09-15) - -### Fix - -- update project repo links, kallithea -> forgejo -- use better icon for submit button on login page -- wrap notes text for batch view -- expose datasync consumer batch size via configure page - -## v0.21.9 (2024-08-28) - -### Fix - -- render custom attrs in form component tag - -## v0.21.8 (2024-08-28) - -### Fix - -- ignore session kwarg for `MasterView.make_row_grid()` - -## v0.21.7 (2024-08-28) - -### Fix - -- avoid error when form value cannot be obtained - -## v0.21.6 (2024-08-28) - -### Fix - -- avoid error when grid value cannot be obtained - -## v0.21.5 (2024-08-28) - -### Fix - -- set empty string for "-new-" file configure option - -## v0.21.4 (2024-08-26) - -### Fix - -- handle differing email profile keys for appinfo/configure - -## v0.21.3 (2024-08-26) - -### Fix - -- show non-standard config values for app info configure email - -## v0.21.2 (2024-08-26) - -### Fix - -- refactor waterpark base template to use wutta feedback component -- fix input/output file upload feature for configure pages, per oruga -- tweak how grid data translates to Vue template context -- merge filters into main grid template -- add basic wutta view for users -- some fixes for wutta people view -- various fixes for waterpark theme -- avoid deprecated `component` form kwarg - -## v0.21.1 (2024-08-22) - -### Fix - -- misc. bugfixes per recent changes - -## v0.21.0 (2024-08-22) - -### Feat - -- move "most" filtering logic for grid class to wuttaweb -- inherit from wuttaweb templates for home, login pages -- inherit from wuttaweb for AppInfoView, appinfo/configure template -- add "has output file templates" config option for master view - -### Fix - -- change grid reset-view param name to match wuttaweb -- move "searchable columns" grid feature to wuttaweb -- use wuttaweb to get/render csrf token -- inherit from wuttaweb for appinfo/index template -- prefer wuttaweb config for "home redirect to login" feature -- fix master/index template rendering for waterpark theme -- fix spacing for navbar logo/title in waterpark theme - -## v0.20.1 (2024-08-20) - -### Fix - -- fix default filter verbs logic for workorder status - -## v0.20.0 (2024-08-20) - -### Feat - -- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy -- refactor templates to simplify base/page/form structure - -### Fix - -- avoid deprecated reference to app db engine - -## v0.19.3 (2024-08-19) - -### Fix - -- add pager stats to all grid vue data (fixes view history) - -## v0.19.2 (2024-08-19) - -### Fix - -- sort on frontend for appinfo package listing grid -- prefer attr over key lookup when getting model values -- replace all occurrences of `component_studly` => `vue_component` - -## v0.19.1 (2024-08-19) - -### Fix - -- fix broken user auth for web API app - -## v0.19.0 (2024-08-18) - -### Feat - -- move multi-column grid sorting logic to wuttaweb -- move single-column grid sorting logic to wuttaweb - -### Fix - -- fix misc. errors in grid template per wuttaweb -- fix broken permission directives in web api startup - -## v0.18.0 (2024-08-16) - -### Feat - -- move "basic" grid pagination logic to wuttaweb -- inherit from wutta base class for Grid -- inherit most logic from wuttaweb, for GridAction - -### Fix - -- avoid route error in user view, when using wutta people view -- fix some more wutta compat for base template - -## v0.17.0 (2024-08-15) - -### Feat - -- use wuttaweb for `get_liburl()` logic - -## v0.16.1 (2024-08-15) - -### Fix - -- improve wutta People view a bit -- update references to `get_class_hierarchy()` -- tweak template for `people/view_profile` per wutta compat - -## v0.16.0 (2024-08-15) - -### Feat - -- add first wutta-based master, for PersonView -- refactor forms/grids/views/templates per wuttaweb compat - -## v0.15.6 (2024-08-13) - -### Fix - -- avoid `before_render` subscriber hook for web API -- simplify verbiage for batch execution panel - -## v0.15.5 (2024-08-09) - -### Fix - -- assign convenience attrs for all views (config, app, enum, model) - -## v0.15.4 (2024-08-09) - -### Fix - -- avoid bug when checking current theme - -## v0.15.3 (2024-08-08) - -### Fix - -- fix timepicker `parseTime()` when value is null - -## v0.15.2 (2024-08-06) - -### Fix - -- use auth handler, avoid legacy calls for role/perm checks - -## v0.15.1 (2024-08-05) - -### Fix - -- move magic `b` template context var to wuttaweb - -## v0.15.0 (2024-08-05) - -### Feat - -- move more subscriber logic to wuttaweb - -### Fix - -- use wuttaweb logic for `util.get_form_data()` - ## v0.14.5 (2024-08-03) ### Fix diff --git a/README.md b/README.rst similarity index 56% rename from README.md rename to README.rst index 74c007f6..0cffc62d 100644 --- a/README.md +++ b/README.rst @@ -1,8 +1,10 @@ -# Tailbone +Tailbone +======== Tailbone is an extensible web application based on Rattail. It provides a "back-office network environment" (BONE) for use in managing retail data. -Please see Rattail's [home page](http://rattailproject.org/) for more -information. +Please see Rattail's `home page`_ for more information. + +.. _home page: http://rattailproject.org/ diff --git a/docs/api/util.rst b/docs/api/util.rst deleted file mode 100644 index 35e66ed3..00000000 --- a/docs/api/util.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``tailbone.util`` -================= - -.. automodule:: tailbone.util - :members: diff --git a/docs/conf.py b/docs/conf.py index ade4c92a..52e384f5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,10 +27,10 @@ templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] intersphinx_mapping = { - 'rattail': ('https://docs.wuttaproject.org/rattail/', None), + 'rattail': ('https://rattailproject.org/docs/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), - 'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None), - 'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None), + 'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None), + 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), } # allow todo entries to show up diff --git a/docs/index.rst b/docs/index.rst index d964086f..3ca6d4e2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,7 +52,6 @@ Package API: api/grids.core api/progress api/subscribers - api/util api/views/batch api/views/batch.vendorcatalog api/views/core diff --git a/pyproject.toml b/pyproject.toml index a7214a8e..0783f2bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,9 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.7" +version = "0.14.5" description = "Backoffice Web Application for Rattail" -readme = "README.md" +readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] license = {text = "GNU GPL v3+"} classifiers = [ @@ -53,13 +53,13 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.20.1", + "rattail[db,bouncer]>=0.17.0", "sa-filters", "simplejson", "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.21.0", + "WuttaWeb>=0.2.0", "zope.sqlalchemy>=1.5", ] @@ -84,9 +84,9 @@ tailbone = "tailbone.config:ConfigExtension" [project.urls] Homepage = "https://rattailproject.org" -Repository = "https://forgejo.wuttaproject.org/rattail/tailbone" -Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues" -Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md" +Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone" +Issues = "https://redmine.rattailproject.org/projects/tailbone/issues" +Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md" [tool.commitizen] diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index a710e30d..1b347b21 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,8 @@ Tailbone Web API - Auth Views """ +from rattail.db.auth import set_user_password + from cornice import Service from tailbone.api import APIView, api @@ -40,10 +42,11 @@ class AuthenticationView(APIView): This will establish a server-side web session for the user if none exists. Note that this also resets the user's session timer. """ - data = {'ok': True, 'permissions': []} + data = {'ok': True} if self.request.user: data['user'] = self.get_user_info(self.request.user) - data['permissions'] = list(self.request.user_permissions) + + data['permissions'] = list(self.request.tailbone_cached_permissions) # background color may be set per-request, by some apps if hasattr(self.request, 'background_color') and self.request.background_color: @@ -173,8 +176,7 @@ class AuthenticationView(APIView): return {'error': "The current/old password you provided is incorrect"} # okay then, set new password - auth = self.app.get_auth_handler() - auth.set_user_password(self.request.user, data['new_password']) + set_user_password(self.request.user, data['new_password']) return { 'ok': True, 'user': self.get_user_info(self.request.user), diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index b23bff55..daa4290f 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -29,7 +29,8 @@ import logging import humanize import sqlalchemy as sa -from rattail.db.model import PurchaseBatch, PurchaseBatchRow +from rattail.db import model +from rattail.util import pretty_quantity from cornice import Service from deform import widget as dfwidget @@ -44,7 +45,7 @@ log = logging.getLogger(__name__) class ReceivingBatchViews(APIBatchView): - model_class = PurchaseBatch + model_class = model.PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receivingbatchviews' permission_prefix = 'receiving' @@ -54,8 +55,7 @@ class ReceivingBatchViews(APIBatchView): supports_execute = True def base_query(self): - model = self.app.model - query = super().base_query() + query = super(ReceivingBatchViews, self).base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) return query @@ -85,7 +85,7 @@ class ReceivingBatchViews(APIBatchView): # assume "receive from PO" if given a PO key if data.get('purchase_key'): - data['workflow'] = 'from_po' + data['receiving_workflow'] = 'from_po' return super().create_object(data) @@ -120,7 +120,6 @@ class ReceivingBatchViews(APIBatchView): return self._get(obj=batch) def eligible_purchases(self): - model = self.app.model uuid = self.request.params.get('vendor_uuid') vendor = self.Session.get(model.Vendor, uuid) if uuid else None if not vendor: @@ -177,7 +176,7 @@ class ReceivingBatchViews(APIBatchView): class ReceivingBatchRowViews(APIBatchRowView): - model_class = PurchaseBatchRow + model_class = model.PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receiving.rows' permission_prefix = 'receiving' @@ -186,8 +185,7 @@ class ReceivingBatchRowViews(APIBatchRowView): supports_quick_entry = True def make_filter_spec(self): - model = self.app.model - filters = super().make_filter_spec() + filters = super(ReceivingBatchRowViews, self).make_filter_spec() if filters: # must translate certain convenience filters @@ -298,11 +296,11 @@ class ReceivingBatchRowViews(APIBatchRowView): return filters def normalize(self, row): - data = super().normalize(row) - model = self.app.model + data = super(ReceivingBatchRowViews, self).normalize(row) batch = row.batch - prodder = self.app.get_products_handler() + app = self.get_rattail_app() + prodder = app.get_products_handler() data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id @@ -377,7 +375,7 @@ class ReceivingBatchRowViews(APIBatchRowView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = self.app.render_quantity(remainder) + remainder = pretty_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive Remainder ({} {})".format( remainder, data['unit_uom']) @@ -388,7 +386,7 @@ class ReceivingBatchRowViews(APIBatchRowView): else: # nothing yet accounted for, button should receive "all" if not remainder: log.warning("quick receive remainder is empty for row %s", row.uuid) - remainder = self.app.render_quantity(remainder) + remainder = pretty_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive ALL ({} {})".format( remainder, data['unit_uom']) @@ -416,7 +414,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['received_alert'] = None if self.batch_handler.get_units_confirmed(row): msg = "You have already received some of this product; last update was {}.".format( - humanize.naturaltime(self.app.make_utc() - row.modified)) + humanize.naturaltime(app.make_utc() - row.modified)) data['received_alert'] = msg return data @@ -425,8 +423,6 @@ class ReceivingBatchRowViews(APIBatchRowView): """ View which handles "receiving" against a particular batch row. """ - model = self.app.model - # first do basic input validation schema = ReceiveRow().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 551d6428..2d17339e 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -26,6 +26,7 @@ Tailbone Web API - Master View import json +from rattail.config import parse_bool from rattail.db.util import get_fieldnames from cornice import resource, Service @@ -184,7 +185,7 @@ class APIMasterView(APIView): if sortcol: spec = { 'field': sortcol.field_name, - 'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc', + 'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc', } if sortcol.model_name: spec['model'] = sortcol.model_name diff --git a/tailbone/app.py b/tailbone/app.py index d2d0c5ef..b7220703 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -25,15 +25,19 @@ Application Entry Point """ import os +import warnings +import sqlalchemy as sa from sqlalchemy.orm import sessionmaker, scoped_session from wuttjamaican.util import parse_list from rattail.config import make_config from rattail.exceptions import ConfigurationError +from rattail.db.types import GPCType from pyramid.config import Configurator +from pyramid.authentication import SessionAuthenticationPolicy from zope.sqlalchemy import register import tailbone.db @@ -62,20 +66,9 @@ def make_rattail_config(settings): # nb. this is for compaibility with wuttaweb settings['wutta_config'] = rattail_config - # must import all sqlalchemy models before things get rolling, - # otherwise can have errors about continuum TransactionMeta class - # not yet mapped, when relevant pages are first requested... - # cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models - # hat tip to https://stackoverflow.com/a/59241485 - if getattr(rattail_config, 'tempmon_engine', None): - from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession - tempmon_session = TempmonSession() - tempmon_session.query(tempmon_model.Appliance).first() - tempmon_session.close() - # configure database sessions - if hasattr(rattail_config, 'appdb_engine'): - tailbone.db.Session.configure(bind=rattail_config.appdb_engine) + if hasattr(rattail_config, 'rattail_engine'): + tailbone.db.Session.configure(bind=rattail_config.rattail_engine) if hasattr(rattail_config, 'trainwreck_engine'): tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) if hasattr(rattail_config, 'tempmon_engine'): @@ -196,16 +189,9 @@ def make_pyramid_config(settings, configure_csrf=True): for spec in includes: config.include(spec) - # add some permissions magic - config.add_directive('add_wutta_permission_group', - 'wuttaweb.auth.add_permission_group') - config.add_directive('add_wutta_permission', - 'wuttaweb.auth.add_permission') - # TODO: deprecate / remove these - config.add_directive('add_tailbone_permission_group', - 'wuttaweb.auth.add_permission_group') - config.add_directive('add_tailbone_permission', - 'wuttaweb.auth.add_permission') + # Add some permissions magic. + config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') + config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') # and some similar magic for certain master views config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') @@ -332,8 +318,7 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - settings.setdefault('mako.directories', ['tailbone:templates', - 'wuttaweb:templates']) + settings.setdefault('mako.directories', ['tailbone:templates']) rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) pyramid_config.include('tailbone') diff --git a/tailbone/auth.py b/tailbone/auth.py index 95bf90ba..826c5d40 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -27,18 +27,20 @@ Authentication & Authorization import logging import re -from wuttjamaican.util import UNSPECIFIED +from rattail.util import prettify, NOTSET +from zope.interface import implementer +from pyramid.authentication import SessionAuthenticationHelper +from pyramid.request import RequestLocalCache from pyramid.security import remember, forget -from wuttaweb.auth import WuttaSecurityPolicy from tailbone.db import Session log = logging.getLogger(__name__) -def login_user(request, user, timeout=UNSPECIFIED): +def login_user(request, user, timeout=NOTSET): """ Perform the steps necessary to login the given user. Note that this returns a ``headers`` dict which you should pass to the redirect. @@ -47,7 +49,7 @@ def login_user(request, user, timeout=UNSPECIFIED): app = config.get_app() user.record_event(app.enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) - if timeout is UNSPECIFIED: + if timeout is NOTSET: timeout = session_timeout_for_user(config, user) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) set_session_timeout(request, timeout) @@ -92,12 +94,12 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None -class TailboneSecurityPolicy(WuttaSecurityPolicy): +class TailboneSecurityPolicy: - def __init__(self, db_session=None, api_mode=False, **kwargs): - kwargs['db_session'] = db_session or Session() - super().__init__(**kwargs) + def __init__(self, api_mode=False): self.api_mode = api_mode + self.session_helper = SessionAuthenticationHelper() + self.identity_cache = RequestLocalCache(self.load_identity) def load_identity(self, request): config = request.registry.settings.get('rattail_config') @@ -113,7 +115,7 @@ class TailboneSecurityPolicy(WuttaSecurityPolicy): if match: token = match.group(1) auth = app.get_auth_handler() - user = auth.authenticate_user_token(self.db_session, token) + user = auth.authenticate_user_token(Session(), token) if not user: @@ -124,10 +126,63 @@ class TailboneSecurityPolicy(WuttaSecurityPolicy): # fetch user object from db model = app.model - user = self.db_session.get(model.User, uuid) + user = Session.get(model.User, uuid) if not user: return # this user is responsible for data changes in current request - self.db_session.set_continuum_user(user) + Session().set_continuum_user(user) return user + + def identity(self, request): + return self.identity_cache.get_or_create(request) + + def authenticated_userid(self, request): + user = self.identity(request) + if user is not None: + return user.uuid + + def remember(self, request, userid, **kw): + return self.session_helper.remember(request, userid, **kw) + + def forget(self, request, **kw): + return self.session_helper.forget(request, **kw) + + def permits(self, request, context, permission): + # nb. root user can do anything + if request.is_root: + return True + + config = request.registry.settings.get('rattail_config') + app = config.get_app() + auth = app.get_auth_handler() + + user = self.identity(request) + return auth.has_permission(Session(), user, permission) + + +def add_permission_group(config, key, label=None, overwrite=True): + """ + Add a permission group to the app configuration. + """ + def action(): + perms = config.get_settings().get('tailbone_permissions', {}) + if key not in perms or overwrite: + group = perms.setdefault(key, {'key': key}) + group['label'] = label or prettify(key) + config.add_settings({'tailbone_permissions': perms}) + config.action(None, action) + + +def add_permission(config, groupkey, key, label=None): + """ + Add a permission to the app configuration. + """ + def action(): + perms = config.get_settings().get('tailbone_permissions', {}) + group = perms.setdefault(groupkey, {'key': groupkey}) + group.setdefault('label', prettify(groupkey)) + perm = group.setdefault('perms', {}).setdefault(key, {'key': key}) + perm['label'] = label or prettify(key) + config.add_settings({'tailbone_permissions': perms}) + config.action(None, action) diff --git a/tailbone/config.py b/tailbone/config.py index 8392ba0a..ce1691ae 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -26,14 +26,13 @@ Rattail config extension for Tailbone import warnings -from wuttjamaican.conf import WuttaConfigExtension - +from rattail.config import ConfigExtension as BaseExtension from rattail.db.config import configure_session from tailbone.db import Session -class ConfigExtension(WuttaConfigExtension): +class ConfigExtension(BaseExtension): """ Rattail config extension for Tailbone. Does the following: diff --git a/tailbone/diffs.py b/tailbone/diffs.py index 2e582b15..98253c57 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -270,21 +270,9 @@ class VersionDiff(Diff): for field in self.fields: values[field] = {'before': self.render_old_value(field), 'after': self.render_new_value(field)} - - operation = None - if self.version.operation_type == continuum.Operation.INSERT: - operation = 'INSERT' - elif self.version.operation_type == continuum.Operation.UPDATE: - operation = 'UPDATE' - elif self.version.operation_type == continuum.Operation.DELETE: - operation = 'DELETE' - else: - operation = self.version.operation_type - return { 'key': id(self.version), 'model_title': self.title, - 'operation': operation, 'diff_class': self.nature, 'fields': self.fields, 'values': values, diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 4024557b..11d489a7 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -35,7 +35,7 @@ from sqlalchemy import orm from sqlalchemy.ext.associationproxy import AssociationProxy, ASSOCIATION_PROXY from wuttjamaican.util import UNSPECIFIED -from rattail.util import pretty_boolean +from rattail.util import prettify, pretty_boolean from rattail.db.util import get_fieldnames import colander @@ -47,10 +47,8 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from wuttaweb.util import FieldList, get_form_data, make_json_safe - from tailbone.db import Session -from tailbone.util import raw_datetime, render_markdown +from tailbone.util import raw_datetime, get_form_data, render_markdown from tailbone.forms import types from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget, @@ -328,7 +326,7 @@ class Form(object): """ Base class for all forms. """ - save_label = "Submit" + save_label = "Save" update_label = "Save" show_cancel = True auto_disable = True @@ -339,12 +337,10 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, - vue_tagname=None, + action_url=None, cancel_url=None, component='tailbone-form', vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, - **kwargs ): self.fields = None if fields is not None: @@ -382,17 +378,7 @@ class Form(object): self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url - - # vue_tagname - self.vue_tagname = vue_tagname - if not self.vue_tagname and kwargs.get('component'): - warnings.warn("component kwarg is deprecated for Form(); " - "please use vue_tagname param instead", - DeprecationWarning, stacklevel=2) - self.vue_tagname = kwargs['component'] - if not self.vue_tagname: - self.vue_tagname = 'tailbone-form' - + self.component = component self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} self.json_data = json_data or {} @@ -401,59 +387,13 @@ class Form(object): self.edit_help_url = edit_help_url self.route_prefix = route_prefix - self.button_icon_submit = kwargs.get('button_icon_submit', 'save') - def __iter__(self): return iter(self.fields) - @property - def vue_component(self): - """ - String name for the Vue component, e.g. ``'TailboneGrid'``. - - This is a generated value based on :attr:`vue_tagname`. - """ - words = self.vue_tagname.split('-') - return ''.join([word.capitalize() for word in words]) - - @property - def component(self): - """ - DEPRECATED - use :attr:`vue_tagname` instead. - """ - warnings.warn("Form.component is deprecated; " - "please use vue_tagname instead", - DeprecationWarning, stacklevel=2) - return self.vue_tagname - @property def component_studly(self): - """ - DEPRECATED - use :attr:`vue_component` instead. - """ - warnings.warn("Form.component_studly is deprecated; " - "please use vue_component instead", - DeprecationWarning, stacklevel=2) - return self.vue_component - - def get_button_label_submit(self): - """ """ - if hasattr(self, '_button_label_submit'): - return self._button_label_submit - - label = getattr(self, 'submit_label', None) - if label: - return label - - return self.save_label - - def set_button_label_submit(self, value): - """ """ - self._button_label_submit = value - - # wutta compat - button_label_submit = property(get_button_label_submit, - set_button_label_submit) + words = self.component.split('-') + return ''.join([word.capitalize() for word in words]) def __contains__(self, item): return item in self.fields @@ -630,9 +570,7 @@ class Form(object): self.schema[key].title = label def get_label(self, key): - config = self.request.rattail_config - app = config.get_app() - return self.labels.get(key, app.make_title(key)) + return self.labels.get(key, prettify(key)) def set_readonly(self, key, readonly=True): if readonly: @@ -863,10 +801,6 @@ class Form(object): DeprecationWarning, stacklevel=2) return self.render_deform(**kwargs) - def get_deform(self): - """ """ - return self.make_deform_form() - def make_deform_form(self): if not hasattr(self, 'deform_form'): @@ -905,11 +839,6 @@ class Form(object): return self.deform_form - def render_vue_template(self, template='/forms/deform.mako', **context): - """ """ - output = self.render_deform(template=template, **context) - return HTML.literal(output) - def render_deform(self, dform=None, template=None, **kwargs): if not template: template = '/forms/deform.mako' @@ -932,8 +861,8 @@ class Form(object): context.setdefault('form_kwargs', {}) # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: - context['form_kwargs'].setdefault('ref', self.vue_component) - context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component) + context['form_kwargs'].setdefault('ref', self.component_studly) + context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) if self.focus_spec: context['form_kwargs']['data-focus'] = self.focus_spec context['request'] = self.request @@ -945,13 +874,12 @@ class Form(object): return dict([(field, self.get_label(field)) for field in self]) - def get_field_markdowns(self, session=None): + def get_field_markdowns(self): app = self.request.rattail_config.get_app() model = app.model - session = session or Session() if not hasattr(self, 'field_markdowns'): - infos = session.query(model.TailboneFieldInfo)\ + infos = Session.query(model.TailboneFieldInfo)\ .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\ .all() self.field_markdowns = dict([(info.field_name, info.markdown_text) @@ -959,18 +887,6 @@ class Form(object): return self.field_markdowns - def get_vue_field_value(self, key): - """ """ - if key not in self.fields: - return - - dform = self.get_deform() - if key not in dform: - return - - field = dform[key] - return make_json_safe(field.cstruct) - def get_vuejs_model_value(self, field): """ This method must return "raw" JS which will be assigned as the initial @@ -1037,11 +953,7 @@ class Form(object): def set_vuejs_component_kwargs(self, **kwargs): self.vuejs_component_kwargs.update(kwargs) - def render_vue_tag(self, **kwargs): - """ """ - return self.render_vuejs_component(**kwargs) - - def render_vuejs_component(self, **kwargs): + def render_vuejs_component(self): """ Render the Vue.js component HTML for the form. @@ -1052,11 +964,10 @@ class Form(object): <tailbone-form :configure-fields-help="configureFieldsHelp"> </tailbone-form> """ - kw = dict(self.vuejs_component_kwargs) - kw.update(kwargs) + kwargs = dict(self.vuejs_component_kwargs) if self.can_edit_help: - kw.setdefault(':configure-fields-help', 'configureFieldsHelp') - return HTML.tag(self.vue_tagname, **kw) + kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') + return HTML.tag(self.component, **kwargs) def set_json_data(self, key, value): """ @@ -1082,12 +993,7 @@ class Form(object): templates.append(HTML.literal(render(template, context))) return HTML.literal('\n').join(templates) - def render_vue_field(self, fieldname, **kwargs): - """ """ - return self.render_field_complete(fieldname, **kwargs) - - def render_field_complete(self, fieldname, bfield_attrs={}, - session=None): + def render_field_complete(self, fieldname, bfield_attrs={}): """ Render the given field completely, i.e. with ``<b-field>`` wrapper. Note that this is meant to render *editable* fields, @@ -1105,7 +1011,7 @@ class Form(object): if self.field_visible(fieldname): label = self.get_label(fieldname) - markdowns = self.get_field_markdowns(session=session) + markdowns = self.get_field_markdowns() # these attrs will be for the <b-field> (*not* the widget) attrs = { @@ -1224,18 +1130,6 @@ class Form(object): # TODO: again, why does serialize() not return literal? return HTML.literal(field.serialize()) - # TODO: this was copied from wuttaweb; can remove when we align - # Form class structure - def render_vue_finalize(self): - """ """ - set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}" - make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})" - return HTML.tag('script', c=['\n', - HTML.literal(set_data), - '\n', - HTML.literal(make_component), - '\n']) - def render_field_readonly(self, field_name, **kwargs): """ Render the given field completely, but in read-only fashion. @@ -1375,19 +1269,12 @@ class Form(object): def obtain_value(self, record, field_name): if record: - - if isinstance(record, dict): - return record[field_name] - - try: - return getattr(record, field_name) - except AttributeError: - pass - try: return record[field_name] + except KeyError: + return None except TypeError: - pass + return getattr(record, field_name, None) # TODO: is this always safe to do? elif self.defaults and field_name in self.defaults: @@ -1441,6 +1328,30 @@ class Form(object): return False +class FieldList(list): + """ + Convenience wrapper for a form's field list. + """ + + def insert_before(self, field, newfield): + if field in self: + i = self.index(field) + self.insert(i, newfield) + else: + log.warning("field '%s' not found, will append new field: %s", + field, newfield) + self.append(newfield) + + def insert_after(self, field, newfield): + if field in self: + i = self.index(field) + self.insert(i + 1, newfield) + else: + log.warning("field '%s' not found, will append new field: %s", + field, newfield) + self.append(newfield) + + @colander.deferred def upload_widget(node, kw): request = kw['request'] diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 56b97b86..b4610a18 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -24,15 +24,13 @@ Core Grid Classes """ -import inspect -import logging -import warnings from urllib.parse import urlencode +import warnings +import logging import sqlalchemy as sa from sqlalchemy import orm -from wuttjamaican.util import UNSPECIFIED from rattail.db.types import GPCType from rattail.util import prettify, pretty_boolean @@ -40,8 +38,6 @@ from pyramid.renderers import render from webhelpers2.html import HTML, tags from paginate_sqlalchemy import SqlalchemyOrmPage -from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo -from wuttaweb.util import FieldList from . import filters as gridfilters from tailbone.db import Session from tailbone.util import raw_datetime @@ -50,17 +46,23 @@ from tailbone.util import raw_datetime log = logging.getLogger(__name__) -class Grid(WuttaGrid): +class FieldList(list): + """ + Convenience wrapper for a field list. """ - Base class for all grids. - This is now a subclass of - :class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add - customizations which have traditionally been part of Tailbone. + def insert_before(self, field, newfield): + i = self.index(field) + self.insert(i, newfield) - Some of these customizations are still undocumented. Some will - eventually be moved to the upstream/parent class, and possibly - some will be removed outright. What docs we have, are shown here. + def insert_after(self, field, newfield): + i = self.index(field) + self.insert(i + 1, newfield) + + +class Grid: + """ + Core grid class. In sore need of documentation. .. _Buefy docs: https://buefy.org/documentation/table/ @@ -183,92 +185,31 @@ class Grid(WuttaGrid): grid.row_uuid_getter = fake_uuid """ - def __init__( - self, - request, - key=None, - data=None, - width='auto', - model_title=None, - model_title_plural=None, - enums={}, - assume_local_times=False, - invisible=[], - raw_renderers={}, - extra_row_class=None, - url='#', - use_byte_string_filters=False, - checkboxes=False, - checked=None, - check_handler=None, - check_all_handler=None, - checkable=None, - row_uuid_getter=None, - clicking_row_checks_box=False, - click_handlers=None, - main_actions=[], - more_actions=[], - delete_speedbump=False, - ajax_data_url=None, - expose_direct_link=False, - **kwargs, - ): - if 'component' in kwargs: - warnings.warn("component param is deprecated for Grid(); " - "please use vue_tagname param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('vue_tagname', kwargs.pop('component')) + def __init__(self, key, data, columns=None, width='auto', request=None, + model_class=None, model_title=None, model_title_plural=None, + enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[], + raw_renderers={}, + extra_row_class=None, linked_columns=[], url='#', + joiners={}, filterable=False, filters={}, use_byte_string_filters=False, + searchable={}, + sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc', + pageable=False, default_pagesize=None, default_page=1, + checkboxes=False, checked=None, check_handler=None, check_all_handler=None, + checkable=None, row_uuid_getter=None, + clicking_row_checks_box=False, click_handlers=None, + main_actions=[], more_actions=[], delete_speedbump=False, + ajax_data_url=None, component='tailbone-grid', + expose_direct_link=False, + **kwargs): - if 'default_sortkey' in kwargs: - warnings.warn("default_sortkey param is deprecated for Grid(); " - "please use sort_defaults param instead", - DeprecationWarning, stacklevel=2) - if 'default_sortdir' in kwargs: - warnings.warn("default_sortdir param is deprecated for Grid(); " - "please use sort_defaults param instead", - DeprecationWarning, stacklevel=2) - if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs: - sortkey = kwargs.pop('default_sortkey', None) - sortdir = kwargs.pop('default_sortdir', 'asc') - if sortkey: - kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) - - if 'pageable' in kwargs: - warnings.warn("pageable param is deprecated for Grid(); " - "please use paginated param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('paginated', kwargs.pop('pageable')) - - if 'default_pagesize' in kwargs: - warnings.warn("default_pagesize param is deprecated for Grid(); " - "please use pagesize param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('pagesize', kwargs.pop('default_pagesize')) - - if 'default_page' in kwargs: - warnings.warn("default_page param is deprecated for Grid(); " - "please use page param instead", - DeprecationWarning, stacklevel=2) - 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 - # reference grid.vue_component etc. - 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['data'] = data - super().__init__(request, **kwargs) + self.key = key + self.data = data + self.columns = FieldList(columns) if columns is not None else None + self.width = width + self.request = request + self.model_class = model_class + if self.model_class and self.columns is None: + self.columns = self.make_columns() self.model_title = model_title if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'): @@ -281,13 +222,32 @@ class Grid(WuttaGrid): if not self.model_title_plural: self.model_title_plural = '{}s'.format(self.model_title) - self.width = width self.enums = enums or {} - self.renderers = self.make_default_renderers(self.renderers) + + self.labels = labels or {} + self.assume_local_times = assume_local_times + self.renderers = self.make_default_renderers(renderers or {}) self.raw_renderers = raw_renderers or {} self.invisible = invisible or [] self.extra_row_class = extra_row_class + self.linked_columns = linked_columns or [] 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.sortable = sortable + self.sorters = self.make_sorters(sorters) + self.default_sortkey = default_sortkey + self.default_sortdir = default_sortdir + + self.pageable = pageable + self.default_pagesize = default_pagesize + self.default_page = default_page self.checkboxes = checkboxes self.checked = checked @@ -301,104 +261,43 @@ class Grid(WuttaGrid): self.click_handlers = click_handlers or {} + self.main_actions = main_actions or [] + self.more_actions = more_actions or [] self.delete_speedbump = delete_speedbump if ajax_data_url: self.ajax_data_url = ajax_data_url elif self.request: - self.ajax_data_url = self.request.path_url + self.ajax_data_url = self.request.current_route_url(_query=None) else: self.ajax_data_url = '' - self.main_actions = main_actions or [] - if self.main_actions: - warnings.warn("main_actions param is deprecated for Grdi(); " - "please use actions param instead", - DeprecationWarning, stacklevel=2) - self.actions.extend(self.main_actions) - self.more_actions = more_actions or [] - if self.more_actions: - warnings.warn("more_actions param is deprecated for Grdi(); " - "please use actions param instead", - DeprecationWarning, stacklevel=2) - self.actions.extend(self.more_actions) - + self.component = component self.expose_direct_link = expose_direct_link self._whgrid_kwargs = kwargs - @property - def component(self): - """ """ - warnings.warn("Grid.component is deprecated; " - "please use vue_tagname instead", - DeprecationWarning, stacklevel=2) - return self.vue_tagname - @property def component_studly(self): - """ """ - warnings.warn("Grid.component_studly is deprecated; " - "please use vue_component instead", - DeprecationWarning, stacklevel=2) - return self.vue_component + words = self.component.split('-') + return ''.join([word.capitalize() for word in words]) - def get_default_sortkey(self): - """ """ - warnings.warn("Grid.default_sortkey is deprecated; " - "please use Grid.sort_defaults instead", - DeprecationWarning, stacklevel=2) - if self.sort_defaults: - return self.sort_defaults[0].sortkey + def make_columns(self): + """ + Return a default list of columns, based on :attr:`model_class`. + """ + if not self.model_class: + raise ValueError("Must define model_class to use make_columns()") - def set_default_sortkey(self, value): - """ """ - warnings.warn("Grid.default_sortkey is deprecated; " - "please use Grid.sort_defaults instead", - DeprecationWarning, stacklevel=2) - if self.sort_defaults: - info = self.sort_defaults[0] - self.sort_defaults[0] = SortInfo(value, info.sortdir) - else: - self.sort_defaults = [SortInfo(value, 'asc')] + mapper = orm.class_mapper(self.model_class) + return [prop.key for prop in mapper.iterate_properties] - default_sortkey = property(get_default_sortkey, set_default_sortkey) - - def get_default_sortdir(self): - """ """ - warnings.warn("Grid.default_sortdir is deprecated; " - "please use Grid.sort_defaults instead", - DeprecationWarning, stacklevel=2) - if self.sort_defaults: - return self.sort_defaults[0].sortdir - - def set_default_sortdir(self, value): - """ """ - warnings.warn("Grid.default_sortdir is deprecated; " - "please use Grid.sort_defaults instead", - DeprecationWarning, stacklevel=2) - if self.sort_defaults: - info = self.sort_defaults[0] - self.sort_defaults[0] = SortInfo(info.sortkey, value) - else: - raise ValueError("cannot set default_sortdir without default_sortkey") - - default_sortdir = property(get_default_sortdir, set_default_sortdir) - - def get_pageable(self): - """ """ - warnings.warn("Grid.pageable is deprecated; " - "please use Grid.paginated instead", - DeprecationWarning, stacklevel=2) - return self.paginated - - def set_pageable(self, value): - """ """ - warnings.warn("Grid.pageable is deprecated; " - "please use Grid.paginated instead", - DeprecationWarning, stacklevel=2) - self.paginated = value - - pageable = property(get_pageable, set_pageable) + def remove(self, *keys): + """ + This *removes* some column(s) from the grid, altogether. + """ + for key in keys: + if key in self.columns: + self.columns.remove(key) def hide_column(self, key): """ @@ -432,6 +331,9 @@ class Grid(WuttaGrid): if key in self.invisible: self.invisible.remove(key) + def append(self, field): + self.columns.append(field) + def insert_before(self, field, newfield): self.columns.insert_before(field, newfield) @@ -443,54 +345,62 @@ class Grid(WuttaGrid): self.remove(oldfield) def set_joiner(self, key, joiner): - """ """ if joiner is None: - warnings.warn("specifying None is deprecated for Grid.set_joiner(); " - "please use Grid.remove_joiner() instead", - DeprecationWarning, stacklevel=2) - self.remove_joiner(key) + self.joiners.pop(key, None) else: - super().set_joiner(key, joiner) + self.joiners[key] = joiner def set_sorter(self, key, *args, **kwargs): - """ """ - - if len(args) == 1: - if kwargs: - warnings.warn("kwargs are ignored for Grid.set_sorter(); " - "please refactor your code accordingly", - DeprecationWarning, stacklevel=2) - if args[0] is None: - warnings.warn("specifying None is deprecated for Grid.set_sorter(); " - "please use Grid.remove_sorter() instead", - DeprecationWarning, stacklevel=2) - self.remove_sorter(key) - else: - super().set_sorter(key, args[0]) - - elif len(args) == 0: - super().set_sorter(key) - + if len(args) == 1 and args[0] is None: + self.remove_sorter(key) else: - warnings.warn("multiple args are deprecated for Grid.set_sorter(); " - "please refactor your code accordingly", - DeprecationWarning, stacklevel=2) self.sorters[key] = self.make_sorter(*args, **kwargs) - def set_filter(self, key, *args, **kwargs): - """ """ - if len(args) == 1: - if args[0] is None: - warnings.warn("specifying None is deprecated for Grid.set_filter(); " - "please use Grid.remove_filter() instead", - DeprecationWarning, stacklevel=2) - self.remove_filter(key) - return + def remove_sorter(self, key): + self.sorters.pop(key, None) - # TODO: our make_filter() signature differs from upstream, - # so must call it explicitly instead of delegating to super - kwargs.setdefault('label', self.get_label(key)) - self.filters[key] = self.make_filter(key, *args, **kwargs) + def set_sort_defaults(self, sortkey, sortdir='asc'): + self.default_sortkey = sortkey + self.default_sortdir = sortdir + + def set_filter(self, key, *args, **kwargs): + if len(args) == 1 and args[0] is None: + self.remove_filter(key) + else: + if 'label' not in kwargs and key in self.labels: + kwargs['label'] = self.labels[key] + self.filters[key] = self.make_filter(key, *args, **kwargs) + + def set_searchable(self, key, searchable=True): + if searchable: + self.searchable[key] = True + else: + 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): + self.labels[key] = label + if not column_only and key in self.filters: + self.filters[key].label = label + + def get_label(self, key): + """ + Returns the label text for given field key. + """ + return self.labels.get(key, prettify(key)) + + def set_link(self, key, link=True): + if link: + if key not in self.linked_columns: + self.linked_columns.append(key) + else: # unlink + if self.linked_columns and key in self.linked_columns: + self.linked_columns.remove(key) def set_click_handler(self, key, handler): if handler: @@ -501,6 +411,9 @@ class Grid(WuttaGrid): def has_click_handler(self, key): return key in self.click_handlers + def set_renderer(self, key, renderer): + self.renderers[key] = renderer + def set_raw_renderer(self, key, renderer): """ Set or remove the "raw" renderer for the given field. @@ -568,18 +481,12 @@ class Grid(WuttaGrid): if isinstance(obj, sa.engine.Row): return obj._mapping[column_name] - if isinstance(obj, dict): - return obj[column_name] - try: - return getattr(obj, column_name) - except AttributeError: + return obj[column_name] + except KeyError: pass - - try: - return obj[column_name] except TypeError: - pass + return getattr(obj, column_name, None) def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) @@ -694,14 +601,6 @@ class Grid(WuttaGrid): def actions_column_format(self, column_number, row_number, item): 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): """ Returns the default set of filters provided by the grid. @@ -726,6 +625,16 @@ class Grid(WuttaGrid): filters[prop.key] = self.make_filter(prop.key, column) 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): """ Make a filter suitable for use with the given column. @@ -773,103 +682,95 @@ class Grid(WuttaGrid): if filtr.active: yield filtr + def make_sorters(self, sorters=None): + """ + Returns an initial set of sorters which will be available to the grid. + The grid itself may or may not provide some default sorters, and the + ``sorters`` kwarg may contain additions and/or overrides. + """ + sorters, updates = {}, sorters + if self.model_class: + mapper = orm.class_mapper(self.model_class) + for prop in mapper.iterate_properties: + if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'): + sorters[prop.key] = self.make_sorter(prop) + if updates: + sorters.update(updates) + return sorters + + def make_sorter(self, model_property): + """ + Returns a function suitable for a sort map callable, with typical logic + built in for sorting applied to ``field``. + """ + class_ = getattr(model_property, 'class_', self.model_class) + column = getattr(class_, model_property.key) + + def sorter(query, direction): + # TODO: this seems hacky..normally we expect a true query + # of course, but in some cases it may be a list instead. + # if so then we can't actually sort + if isinstance(query, list): + return query + return query.order_by(getattr(column, direction)()) + + sorter._class = class_ + sorter._column = column + + return sorter + def make_simple_sorter(self, key, foldcase=False): - """ """ - warnings.warn("Grid.make_simple_sorter() is deprecated; " - "please use Grid.make_sorter() instead", - DeprecationWarning, stacklevel=2) - return self.make_sorter(key, foldcase=foldcase) - - def get_pagesize_options(self, default=None): - """ """ - # let upstream check config - options = super().get_pagesize_options(default=UNSPECIFIED) - if options is not UNSPECIFIED: - return options - - # fallback to legacy config - options = self.config.get_list('tailbone.grid.pagesize_options') - if options: - warnings.warn("tailbone.grid.pagesize_options setting is deprecated; " - "please set wuttaweb.grids.default_pagesize_options instead", - DeprecationWarning) - options = [int(size) for size in options - if size.isdigit()] - if options: - return options - - if default: - return default - - # use upstream default - return super().get_pagesize_options() - - def get_pagesize(self, default=None): - """ """ - # let upstream check config - pagesize = super().get_pagesize(default=UNSPECIFIED) - if pagesize is not UNSPECIFIED: - return pagesize - - # fallback to legacy config - pagesize = self.config.get_int('tailbone.grid.default_pagesize') - if pagesize: - warnings.warn("tailbone.grid.default_pagesize setting is deprecated; " - "please use wuttaweb.grids.default_pagesize instead", - DeprecationWarning) - return pagesize - - if default: - return default - - # use upstream default - return super().get_pagesize() - - def get_default_pagesize(self): # pragma: no cover - """ """ - warnings.warn("Grid.get_default_pagesize() method is deprecated; " - "please use Grid.get_pagesize() of Grid.page instead", - DeprecationWarning, stacklevel=2) + """ + Returns a function suitable for a sort map callable, with typical logic + built in for sorting a data set comprised of dicts, on the given key. + """ + if foldcase: + keyfunc = lambda v: v[key].lower() + else: + keyfunc = lambda v: v[key] + return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc') + def get_default_pagesize(self): if self.default_pagesize: return self.default_pagesize - return self.get_pagesize() + pagesize = self.request.rattail_config.getint('tailbone', + 'grid.default_pagesize', + default=0) + if pagesize: + return pagesize - def load_settings(self, **kwargs): - """ """ - if 'store' in kwargs: - warnings.warn("the 'store' param is deprecated for load_settings(); " - "please use the 'persist' param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('persist', kwargs.pop('store')) + options = self.get_pagesize_options() + return options[0] - persist = kwargs.get('persist', True) + def load_settings(self, store=True): + """ + Load current/effective settings for the grid, from the request query + string and/or session storage. If ``store`` is true, then once + settings have been fully read, they are stored in current session for + next time. Finally, various instance attributes of the grid and its + filters are updated in-place to reflect the settings; this is so code + needn't access the settings dict directly, but the more Pythonic + instance attributes. + """ # initial default settings settings = {} if self.sortable: - if self.sort_defaults: - # nb. as of writing neither Buefy nor Oruga support a - # multi-column *default* sort; so just use first sorter - sortinfo = self.sort_defaults[0] + if self.default_sortkey: settings['sorters.length'] = 1 - settings['sorters.1.key'] = sortinfo.sortkey - settings['sorters.1.dir'] = sortinfo.sortdir + settings['sorters.1.key'] = self.default_sortkey + settings['sorters.1.dir'] = self.default_sortdir else: settings['sorters.length'] = 0 - if self.paginated: - settings['pagesize'] = self.pagesize - settings['page'] = self.page + if self.pageable: + settings['pagesize'] = self.get_default_pagesize() + settings['page'] = self.default_page if self.filterable: for filtr in self.iter_filters(): - defaults = self.filter_defaults.get(filtr.key, {}) - settings[f'filter.{filtr.key}.active'] = defaults.get('active', - 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) + settings['filter.{}.active'.format(filtr.key)] = filtr.default_active + settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb + settings['filter.{}.value'.format(filtr.key)] = filtr.default_value # If user has default settings on file, apply those first. if self.user_has_defaults(): @@ -877,25 +778,25 @@ class Grid(WuttaGrid): # If request contains instruction to reset to default filters, then we # can skip the rest of the request/session checks. - if self.request.GET.get('reset-view'): + if self.request.GET.get('reset-to-default-filters') == 'true': pass # If request has filter settings, grab those, then grab sort/pager # settings from request or session. - elif self.request_has_settings('filter'): - self.update_filter_settings(settings, src='request') + elif self.filterable and self.request_has_settings('filter'): + self.update_filter_settings(settings, 'request') if self.request_has_settings('sort'): - self.update_sort_settings(settings, src='request') + self.update_sort_settings(settings, 'request') else: - self.update_sort_settings(settings, src='session') + self.update_sort_settings(settings, 'session') self.update_page_settings(settings) # If request has no filter settings but does have sort settings, grab # those, then grab filter settings from session, then grab pager # settings from request or session. elif self.request_has_settings('sort'): - self.update_sort_settings(settings, src='request') - self.update_filter_settings(settings, src='session') + self.update_sort_settings(settings, 'request') + self.update_filter_settings(settings, 'session') self.update_page_settings(settings) # NOTE: These next two are functionally equivalent, but are kept @@ -905,27 +806,27 @@ class Grid(WuttaGrid): # grab those, then grab filter/sort settings from session. elif self.request_has_settings('page'): self.update_page_settings(settings) - self.update_filter_settings(settings, src='session') - self.update_sort_settings(settings, src='session') + self.update_filter_settings(settings, 'session') + self.update_sort_settings(settings, 'session') # If request has no settings, grab all from session. elif self.session_has_settings(): - self.update_filter_settings(settings, src='session') - self.update_sort_settings(settings, src='session') + self.update_filter_settings(settings, 'session') + self.update_sort_settings(settings, 'session') self.update_page_settings(settings) # If no settings were found in request or session, don't store result. else: - persist = False + store = False # Maybe store settings for next time. - if persist: - self.persist_settings(settings, dest='session') + if store: + self.persist_settings(settings, 'session') # If request contained instruction to save current settings as defaults # for the current user, then do that. if self.request.GET.get('save-current-filters-as-defaults') == 'true': - self.persist_settings(settings, dest='defaults') + self.persist_settings(settings, 'defaults') # update ourself to reflect settings if self.filterable: @@ -934,14 +835,13 @@ class Grid(WuttaGrid): filtr.verb = settings['filter.{}.verb'.format(filtr.key)] filtr.value = settings['filter.{}.value'.format(filtr.key)] if self.sortable: - # and self.sort_on_backend: self.active_sorters = [] for i in range(1, settings['sorters.length'] + 1): self.active_sorters.append({ - 'key': settings[f'sorters.{i}.key'], - 'dir': settings[f'sorters.{i}.dir'], + 'field': settings[f'sorters.{i}.key'], + 'order': settings[f'sorters.{i}.dir'], }) - if self.paginated: + if self.pageable: self.pagesize = settings['pagesize'] self.page = settings['page'] @@ -1045,16 +945,23 @@ class Grid(WuttaGrid): merge(f'sorters.{i}.key') merge(f'sorters.{i}.dir') - if self.paginated: + if self.pageable: merge('pagesize', int) merge('page', int) def request_has_settings(self, type_): - """ """ - if super().request_has_settings(type_): - return True + """ + Determine if the current request (GET query string) contains any + filter/sort settings for the grid. + """ + 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 - if type_ == 'sort': + elif type_ == 'sort': # TODO: remove this eventually, but some links in the wild # may still include these params, so leave it for now @@ -1062,6 +969,14 @@ class Grid(WuttaGrid): if key in self.request.GET: 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 def session_has_settings(self): @@ -1077,19 +992,175 @@ class Grid(WuttaGrid): return any([key.startswith(f'{prefix}.filter') for key in self.request.session]) - def persist_settings(self, settings, dest='session'): - """ """ - if dest not in ('defaults', 'session'): - raise ValueError(f"invalid dest identifier: {dest}") + def get_setting(self, source, settings, key, normalize=lambda v: v, default=None): + """ + Get the effective value for a particular setting, preferring ``source`` + but falling back to existing ``settings`` and finally the ``default``. + """ + if source not in ('request', 'session'): + raise ValueError("Invalid source identifier: {}".format(source)) + # If source is query string, try that first. + if source == 'request': + value = self.request.GET.get(key) + if value is not None: + try: + value = normalize(value) + except ValueError: + pass + else: + return value + + # Or, if source is session, try that first. + else: + value = self.request.session.get('grid.{}.{}'.format(self.key, key)) + if value is not None: + return normalize(value) + + # If source had nothing, try default/existing settings. + value = settings.get(key) + if value is not None: + try: + value = normalize(value) + except ValueError: + pass + else: + return value + + # Okay then, default it is. + return default + + 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( + source, settings, '{}.verb'.format(filtr.key), default='') + settings['{}.value'.format(prefix)] = self.get_setting( + source, settings, filtr.key, default='') + + else: # source = session + settings['{}.active'.format(prefix)] = self.get_setting( + source, settings, '{}.active'.format(prefix), + normalize=lambda v: str(v).lower() == 'true', default=False) + settings['{}.verb'.format(prefix)] = self.get_setting( + source, settings, '{}.verb'.format(prefix), default='') + settings['{}.value'.format(prefix)] = self.get_setting( + source, settings, '{}.value'.format(prefix), default='') + + def update_sort_settings(self, settings, source): + """ + Updates a settings dictionary according to sort 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.sortable: + return + + if source == 'request': + + # TODO: remove this eventually, but some links in the wild + # may still include these params, so leave it for now + if 'sortkey' in self.request.GET: + settings['sorters.length'] = 1 + settings['sorters.1.key'] = self.get_setting(source, settings, 'sortkey') + settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir') + + else: # the future + i = 1 + while True: + skey = f'sort{i}key' + if skey in self.request.GET: + settings[f'sorters.{i}.key'] = self.get_setting(source, settings, skey) + settings[f'sorters.{i}.dir'] = self.get_setting(source, settings, f'sort{i}dir') + else: + break + i += 1 + settings['sorters.length'] = i - 1 + + else: # session + + # TODO: definitely will remove this, but leave it for now + # so it doesn't monkey with current user sessions when + # next upgrade happens. so, remove after all are upgraded + sortkey = self.get_setting(source, settings, 'sortkey') + if sortkey: + settings['sorters.length'] = 1 + settings['sorters.1.key'] = sortkey + settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir') + + else: # the future + settings['sorters.length'] = self.get_setting(source, settings, + 'sorters.length', int) + for i in range(1, settings['sorters.length'] + 1): + for key in ('key', 'dir'): + skey = f'sorters.{i}.{key}' + settings[skey] = self.get_setting(source, settings, skey) + + 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.pageable: + 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, to='session'): + """ + Persist the given settings in some way, as defined by ``func``. + """ app = self.request.rattail_config.get_app() model = app.model - def persist(key, value=lambda k: settings.get(k)): - if dest == 'defaults': + def persist(key, value=lambda k: settings[k]): + if to == 'defaults': skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) app.save_setting(Session(), skey, value(key)) - else: # dest == session + else: # to == session skey = 'grid.{}.{}'.format(self.key, key) self.request.session[skey] = value(key) @@ -1101,11 +1172,9 @@ class Grid(WuttaGrid): if self.sortable: - # first must clear all sort settings from dest. this is - # because number of sort settings will vary, so we delete - # all and then write all - - if dest == 'defaults': + # first clear existing settings for *sorting* only + # nb. this is because number of sort settings will vary + if to == 'defaults': prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' query = Session.query(model.Setting)\ .filter(sa.or_( @@ -1119,9 +1188,7 @@ class Grid(WuttaGrid): for setting in query.all(): Session.delete(setting) Session.flush() - else: # session - # remove sort settings from user session prefix = f'grid.{self.key}' for key in list(self.request.session): if key.startswith(f'{prefix}.sorters.'): @@ -1133,14 +1200,12 @@ class Grid(WuttaGrid): self.request.session.pop(f'{prefix}.sortkey', None) self.request.session.pop(f'{prefix}.sortdir', None) - # now save sort settings to dest - if 'sorters.length' in settings: - persist('sorters.length') - for i in range(1, settings['sorters.length'] + 1): - persist(f'sorters.{i}.key') - persist(f'sorters.{i}.dir') + persist('sorters.length') + for i in range(1, settings['sorters.length'] + 1): + persist(f'sorters.{i}.key') + persist(f'sorters.{i}.dir') - if self.paginated: + if self.pageable: persist('pagesize') persist('page') @@ -1164,27 +1229,110 @@ class Grid(WuttaGrid): return data + def sort_data(self, data): + """ + Sort the given query according to current settings, and return the result. + """ + # bail if no sort settings + if not self.active_sorters: + return data + + # TODO: is there a better way to check for SA sorting? + if self.model_class: + + # collect actual column sorters for order_by clause + sorters = [] + for sorter in self.active_sorters: + sortkey = sorter['field'] + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + log.warning("unknown sorter: %s", sorter) + continue + + # 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) + + # add column/dir to collection + sortdir = sorter['order'] + sorters.append(getattr(sortfunc._column, sortdir)()) + + # apply sorting to query + if sorters: + data = data.order_by(*sorters) + + return data + + else: + # not a SQLAlchemy grid, custom sorter + + assert len(self.active_sorters) < 2 + + sortkey = self.active_sorters[0]['field'] + sortdir = self.active_sorters[0]['order'] or 'asc' + + # Cannot sort unless we have a sort function. + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + return data + + # apply joins needed for this sorter + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) + + return sortfunc(data, sortdir) + + 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): - """ """ - warnings.warn("grid.make_visible_data() method is deprecated; " - "please use grid.get_visible_data() instead", - DeprecationWarning, stacklevel=2) - return self.get_visible_data() - - def render_vue_tag(self, master=None, **kwargs): - """ """ - kwargs.setdefault('ref', 'grid') - kwargs.setdefault(':csrftoken', 'csrftoken') - - if (master and master.deletable and master.has_perm('delete') - and master.delete_confirm == 'simple'): - kwargs.setdefault('@deleteActionClicked', 'deleteObject') - - return HTML.tag(self.vue_tagname, **kwargs) - - def render_vue_template(self, template='/grids/complete.mako', **context): - """ """ - return self.render_complete(template=template, **context) + """ + Apply various settings to the raw data set, to produce a final data + set. This will page / sort / filter as necessary, according to the + grid's defaults and the current request etc. + """ + self.joined = set() + data = self.data + if self.filterable: + data = self.filter_data(data) + if self.sortable: + data = self.sort_data(data) + if self.pageable: + self.pager = self.paginate_data(data) + data = self.pager + return data def render_complete(self, template='/grids/complete.mako', **kwargs): """ @@ -1192,7 +1340,7 @@ class Grid(WuttaGrid): includes the context menu items and grid tools. """ if 'grid_columns' not in kwargs: - kwargs['grid_columns'] = self.get_vue_columns() + kwargs['grid_columns'] = self.get_table_columns() if 'grid_data' not in kwargs: kwargs['grid_data'] = self.get_table_data() @@ -1211,11 +1359,9 @@ class Grid(WuttaGrid): context['request'] = self.request context.setdefault('allow_save_defaults', True) context.setdefault('view_click_handler', self.get_view_click_handler()) - html = render(template, context) - return HTML.literal(html) + return render(template, context) def render_buefy(self, **kwargs): - """ """ warnings.warn("Grid.render_buefy() is deprecated; " "please use Grid.render_complete() instead", DeprecationWarning, stacklevel=2) @@ -1223,7 +1369,6 @@ class Grid(WuttaGrid): def render_table_element(self, template='/grids/b-table.mako', data_prop='gridData', empty_labels=False, - literal=False, **kwargs): """ This is intended for ad-hoc "small" grids with static data. Renders @@ -1235,24 +1380,30 @@ class Grid(WuttaGrid): context['data_prop'] = data_prop context['empty_labels'] = empty_labels if 'grid_columns' not in context: - context['grid_columns'] = self.get_vue_columns() + context['grid_columns'] = self.get_table_columns() context.setdefault('paginated', False) if context['paginated']: context.setdefault('per_page', 20) context['view_click_handler'] = self.get_view_click_handler() - result = render(template, context) - if literal: - result = HTML.literal(result) - return result + return render(template, context) def get_view_click_handler(self): - """ """ + # locate the 'view' action # TODO: this should be easier, and/or moved elsewhere? view = None - for action in self.actions: + for action in self.main_actions: if action.key == 'view': - return getattr(action, 'click_handler', None) + view = action + break + if not view: + for action in self.more_actions: + if action.key == 'view': + view = action + break + + if view: + return view.click_handler def set_filters_sequence(self, filters, only=False): """ @@ -1326,21 +1477,48 @@ class Grid(WuttaGrid): return data - def render_actions(self, row, i): # pragma: no cover - """ """ - warnings.warn("grid.render_actions() is deprecated!", - DeprecationWarning, stacklevel=2) + 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 - actions = [self.render_action(a, row, i) - for a in self.actions] - actions = [a for a in actions if a] - return HTML.literal('').join(actions) + form = gridfilters.GridFiltersForm(self.filters, + request=self.request, + defaults=data) - def render_action(self, action, row, i): # pragma: no cover - """ """ - warnings.warn("grid.render_action() is deprecated!", - DeprecationWarning, stacklevel=2) + kwargs['request'] = self.request + kwargs['grid'] = self + kwargs['form'] = form + return render(template, kwargs) + def render_actions(self, row, i): + """ + Returns the rendered contents of the 'actions' column for a given row. + """ + main_actions = [self.render_action(a, row, i) + for a in self.main_actions] + main_actions = [a for a in main_actions if a] + more_actions = [self.render_action(a, row, i) + for a in self.more_actions] + more_actions = [a for a in more_actions if a] + if more_actions: + icon = HTML.tag('span', class_='ui-icon ui-icon-carat-1-e') + link = tags.link_to("More" + icon, '#', class_='more') + main_actions.append(HTML.literal(' ') + link + HTML.tag('div', class_='more', c=more_actions)) + return HTML.literal('').join(main_actions) + + def render_action(self, action, row, i): + """ + Renders an action menu item (link) for the given row. + """ url = action.get_url(row, i) if url: kwargs = {'class_': action.key, 'target': action.target} @@ -1374,6 +1552,18 @@ class Grid(WuttaGrid): return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)), checked=self.checked(item)) + def get_pagesize_options(self): + + # use values from config, if defined + options = self.request.rattail_config.getlist('tailbone', 'grid.pagesize_options') + if options: + options = [int(size) for size in options + if size.isdigit()] + if options: + return options + + return [5, 10, 20, 50, 100, 200] + def has_static_data(self): """ Should return ``True`` if the grid data can be considered "static" @@ -1385,21 +1575,20 @@ class Grid(WuttaGrid): return True return False - def get_vue_columns(self): - """ """ - columns = super().get_vue_columns() - - for column in columns: - column['visible'] = column['field'] not in self.invisible - - return columns - def get_table_columns(self): - """ """ - warnings.warn("grid.get_table_columns() method is deprecated; " - "please use grid.get_vue_columns() instead", - DeprecationWarning, stacklevel=2) - return self.get_vue_columns() + """ + Return a list of dicts representing all grid columns. Meant + for use with the client-side JS table. + """ + columns = [] + for name in self.columns: + columns.append({ + 'field': name, + 'label': self.get_label(name), + 'sortable': self.sortable and name in self.sorters, + 'visible': name not in self.invisible, + }) + return columns def get_uuid_for_row(self, rowobj): @@ -1411,25 +1600,13 @@ class Grid(WuttaGrid): if hasattr(rowobj, 'uuid'): return rowobj.uuid - def get_vue_context(self): - """ """ - return self.get_table_data() - - def get_vue_data(self): - """ """ - table_data = self.get_table_data() - return table_data['data'] - def get_table_data(self): """ Returns a list of data rows for the grid, for use with client-side JS table. """ - if hasattr(self, '_table_data'): - return self._table_data - # filter / sort / paginate to get "visible" data - raw_data = self.get_visible_data() + raw_data = self.make_visible_data() data = [] status_map = {} checked = [] @@ -1470,22 +1647,10 @@ class Grid(WuttaGrid): # leverage configured rendering logic where applicable; # otherwise use "raw" data value as string - value = self.obtain_value(rowobj, name) if self.renderers and name in self.renderers: - renderer = self.renderers[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) - + value = self.renderers[name](rowobj, name) + else: + value = self.obtain_value(rowobj, name) if value is None: value = "" @@ -1518,8 +1683,6 @@ class Grid(WuttaGrid): results = { 'data': data, - 'row_classes': status_map, - # TODO: deprecate / remove this 'row_status_map': status_map, } @@ -1527,15 +1690,11 @@ class Grid(WuttaGrid): results['checked_rows'] = checked # TODO: this seems a bit hacky, but is required for now to # initialize things on the client side... - var = '{}CurrentData'.format(self.vue_component) + var = '{}CurrentData'.format(self.component_studly) results['checked_rows_code'] = '[{}]'.format( ', '.join(['{}[{}]'.format(var, i) for i in checked])) - if self.paginated and self.paginate_on_backend: - results['pager_stats'] = self.get_vue_pager_stats() - - # TODO: is this actually needed now that we have pager_stats? - if self.paginated and self.pager is not None: + if self.pageable and self.pager is not None: results['total_items'] = self.pager.item_count results['per_page'] = self.pager.items_per_page results['page'] = self.pager.page @@ -1545,38 +1704,41 @@ class Grid(WuttaGrid): else: results['total_items'] = count - self._table_data = results - 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)) + return results def set_action_urls(self, row, rowobj, i): """ Pre-generate all action URLs for the given data row. Meant for use with client-side table, since we can't generate URLs from JS. """ - for action in self.actions: + for action in (self.main_actions + self.more_actions): url = action.get_url(rowobj, i) row['_action_url_{}'.format(action.key)] = url + def is_linked(self, name): + """ + Should return ``True`` if the given column name is configured to be + "linked" (i.e. table cell should contain a link to "view object"), + otherwise ``False``. + """ + if self.linked_columns: + if name in self.linked_columns: + return True + return False -class GridAction(WuttaGridAction): + +class GridAction(object): """ - Represents a "row action" hyperlink within a grid context. + Represents an action available to a grid. This is used to construct the + 'actions' column when rendering the grid. - This is a subclass of - :class:`wuttaweb:wuttaweb.grids.base.GridAction`. + :param key: Key for the action (e.g. ``'edit'``), unique within + the grid. - .. warning:: + :param label: Label to be displayed for the action. If not set, + will be a capitalized version of ``key``. - This class remains for now, to retain compatibility with - existing code. But at some point the WuttaWeb class will - supersede this one entirely. - - :param target: HTML "target" attribute for the ``<a>`` tag. + :param icon: Icon name for the action. :param click_handler: Optional JS click handler for the action. This value will be rendered as-is within the final grid @@ -1588,23 +1750,41 @@ class GridAction(WuttaGridAction): * ``$emit('do-something', props.row)`` """ - def __init__( - self, - request, - key, - target=None, - click_handler=None, - **kwargs, - ): - # TODO: previously url default was '#' - but i don't think we - # need that anymore? guess we'll see.. - #kwargs.setdefault('url', '#') - - super().__init__(request, key, **kwargs) - + def __init__(self, key, label=None, url='#', icon=None, target=None, + link_class=None, click_handler=None): + self.key = key + self.label = label or prettify(key) + self.icon = icon + self.url = url self.target = target + self.link_class = link_class self.click_handler = click_handler + def get_url(self, row, i): + """ + Returns an action URL for the given row. + """ + if callable(self.url): + return self.url(row, i) + return self.url + + def render_icon(self): + """ + Render the HTML snippet for the action link icon. + """ + return HTML.tag('i', class_='fas fa-{}'.format(self.icon)) + + def render_label(self): + """ + Render the label "text" within the actions column of a grid + row. Most actions have a static label that never varies, but + you can override this to add e.g. HTML content. Note that the + return value will be treated / rendered as HTML whether or not + it contains any, so perhaps be careful that it is trusted + content. + """ + return self.label + class URLMaker(object): """ diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 50b38c30..d4065cc5 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Template Context Helpers """ -# start off with all from wuttaweb -from wuttaweb.helpers import * - import os import datetime from decimal import Decimal @@ -36,9 +33,14 @@ from rattail.time import localtime, make_utc from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal from rattail.db.util import maxlen -from tailbone.util import (pretty_datetime, raw_datetime, +from webhelpers2.html import * +from webhelpers2.html.tags import * + +from tailbone.util import (csrf_token, get_csrf_token, + pretty_datetime, raw_datetime, render_markdown, - route_exists) + route_exists, + get_liburl) def pretty_date(date): diff --git a/tailbone/menus.py b/tailbone/menus.py index 09d6f3f0..abd0b58b 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -394,11 +394,6 @@ class TailboneMenuHandler(WuttaMenuHandler): 'route': 'products', 'perm': 'products.list', }, - { - 'title': "Product Costs", - 'route': 'product_costs', - 'perm': 'product_costs.list', - }, { 'title': "Departments", 'route': 'departments', @@ -456,11 +451,6 @@ class TailboneMenuHandler(WuttaMenuHandler): 'route': 'vendors', 'perm': 'vendors.list', }, - { - 'title': "Product Costs", - 'route': 'product_costs', - 'perm': 'product_costs.list', - }, {'type': 'sep'}, { 'title': "Ordering", @@ -713,7 +703,7 @@ class TailboneMenuHandler(WuttaMenuHandler): }, {'type': 'sep'}, { - 'title': "App Info", + 'title': "App Details", 'route': 'appinfo', 'perm': 'appinfo.list', }, diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py index 57700b80..2ad5161a 100644 --- a/tailbone/static/__init__.py +++ b/tailbone/static/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2017 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,9 @@ Static Assets """ +from __future__ import unicode_literals, absolute_import + def includeme(config): - config.include('wuttaweb.static') config.add_static_view('tailbone', 'tailbone:static') config.add_static_view('deform', 'deform:static') diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 268d4818..181c84bc 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -48,21 +48,43 @@ from tailbone.util import get_available_themes, get_global_search_options log = logging.getLogger(__name__) -def new_request(event, session=None): +def new_request(event): """ Event hook called when processing a new request. - This first invokes the upstream hooks: - - * :func:`wuttaweb:wuttaweb.subscribers.new_request()` - * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()` + This first invokes the upstream hook: + :func:`wuttaweb:wuttaweb.subscribers.new_request()` It then adds more things to the request object; among them: .. attribute:: request.rattail_config Reference to the app :term:`config object`. Note that this - will be the same as :attr:`wuttaweb:request.wutta_config`. + will be the same as ``request.wutta_config``. + + .. attribute:: request.user + + Reference to the current authenticated user, or ``None``. + + .. attribute:: request.is_admin + + Flag indicating whether current user is a member of the + Administrator role. + + .. attribute:: request.is_root + + Flag indicating whether user is currently elevated to root + privileges. This is only possible if ``request.is_admin = + True``. + + .. method:: request.has_perm(name) + + Function to check if current user has the given permission. + + .. method:: request.has_any_perm(*names) + + Function to check if current user has any of the given + permissions. .. method:: request.register_component(tagname, classname) @@ -72,55 +94,79 @@ def new_request(event, session=None): then in the base template all registered components will be properly loaded. """ + # log.debug("new request: %s", event) request = event.request - # invoke main upstream logic + # invoke upstream logic # nb. this sets request.wutta_config base.new_request(event) config = request.wutta_config app = config.get_app() auth = app.get_auth_handler() - session = session or Session() # compatibility rattail_config = config request.rattail_config = rattail_config - def user_getter(request, db_session=None): - user = base.default_user_getter(request, db_session=db_session) - if user: - # nb. we also assign continuum user to session - session = db_session or Session() - session.set_continuum_user(user) - return user + def user(request): + user = None + uuid = request.authenticated_userid + if uuid: + app = request.rattail_config.get_app() + model = app.model + user = Session.get(model.User, uuid) + if user: + Session().set_continuum_user(user) + return user - # invoke upstream hook to set user - base.new_request_set_user(event, user_getter=user_getter, db_session=session) + request.set_property(user, reify=True) # assign client IP address to the session, for sake of versioning - if hasattr(request, 'client_addr'): - session.continuum_remote_addr = request.client_addr + Session().continuum_remote_addr = request.client_addr - # request.register_component() - def register_component(tagname, classname): - """ - Register a Vue 3 component, so the base template knows to - declare it for use within the app (page). - """ - if not hasattr(request, '_tailbone_registered_components'): - request._tailbone_registered_components = OrderedDict() + request.is_admin = auth.user_is_admin(request.user) + request.is_root = request.is_admin and request.session.get('is_root', False) - if tagname in request._tailbone_registered_components: - log.warning("component with tagname '%s' already registered " - "with class '%s' but we are replacing that with " - "class '%s'", - tagname, - request._tailbone_registered_components[tagname], - classname) + # TODO: why would this ever be null? + if rattail_config: - request._tailbone_registered_components[tagname] = classname - request.register_component = register_component + app = rattail_config.get_app() + auth = app.get_auth_handler() + request.tailbone_cached_permissions = auth.get_permissions( + Session(), request.user) + + def has_perm(name): + if name in request.tailbone_cached_permissions: + return True + return request.is_root + request.has_perm = has_perm + + def has_any_perm(*names): + for name in names: + if has_perm(name): + return True + return False + request.has_any_perm = has_any_perm + + def register_component(tagname, classname): + """ + Register a Vue 3 component, so the base template knows to + declare it for use within the app (page). + """ + if not hasattr(request, '_tailbone_registered_components'): + request._tailbone_registered_components = OrderedDict() + + if tagname in request._tailbone_registered_components: + log.warning("component with tagname '%s' already registered " + "with class '%s' but we are replacing that with " + "class '%s'", + tagname, + request._tailbone_registered_components[tagname], + classname) + + request._tailbone_registered_components[tagname] = classname + request.register_component = register_component def before_render(event): @@ -159,6 +205,7 @@ def before_render(event): # theme - we only want do this for classic web app, *not* API # TODO: so, clearly we need a better way to distinguish the two if 'tailbone.theme' in request.registry.settings: + renderer_globals['b'] = 'o' if request.use_oruga else 'b' # for buefy renderer_globals['theme'] = request.registry.settings['tailbone.theme'] # note, this is just a global flag; user still needs permission to see picker expose_picker = config.get_bool('tailbone.themes.expose_picker', @@ -239,10 +286,27 @@ def context_found(event): The following is attached to the request: + * ``get_referrer()`` function + * ``get_session_timeout()`` function """ request = event.request + def get_referrer(default=None, **kwargs): + if request.params.get('referrer'): + return request.params['referrer'] + if request.session.get('referrer'): + return request.session.pop('referrer') + referrer = request.referrer + if (not referrer or referrer == request.current_route_url() + or not referrer.startswith(request.host_url)): + if default: + referrer = default + else: + referrer = request.route_url('home') + return referrer + request.get_referrer = get_referrer + def get_session_timeout(): """ Returns the timeout in effect for the current session diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 9d866cea..280b5cb9 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -1,2 +1,250 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/appinfo/configure.mako" /> +<%inherit file="/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('tailbone.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.libver.{}']".format(weblib['key'])})} + ${h.hidden('tailbone.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['tailbone.liburl.{}']".format(weblib['key'])})} + % endfor + + <${b}-modal has-modal-card + % 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_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.weblibs = ${json.dumps(weblibs)|n} + + ThisPageData.editWebLibraryShowDialog = false + ThisPageData.editWebLibraryRecord = {} + ThisPageData.editWebLibraryVersion = null + ThisPageData.editWebLibraryURL = null + + ThisPage.methods.editWebLibraryInit = function(row) { + this.editWebLibraryRecord = row + this.editWebLibraryVersion = row.configured_version + this.editWebLibraryURL = row.configured_url + this.editWebLibraryShowDialog = true + } + + ThisPage.methods.editWebLibrarySave = function() { + this.editWebLibraryRecord.configured_version = this.editWebLibraryVersion + this.editWebLibraryRecord.configured_url = this.editWebLibraryURL + this.editWebLibraryRecord.modified = true + + this.simpleSettings[`tailbone.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion + this.simpleSettings[`tailbone.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL + + this.settingsNeedSaved = true + this.editWebLibraryShowDialog = false + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index faaea935..73f53920 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -1,7 +1,8 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/appinfo/index.mako" /> +<%inherit file="/master/index.mako" /> + +<%def name="render_grid_component()"> -<%def name="page_content()"> <div class="buttons"> <once-button type="is-primary" @@ -27,5 +28,100 @@ </div> - ${parent.page_content()} + <${b}-collapse class="panel" open> + + <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%;"> + ${parent.render_grid_component()} + </div> + </div> + </${b}-collapse> </%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n} + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako index ba667e0e..4f935956 100644 --- a/tailbone/templates/appsettings.mako +++ b/tailbone/templates/appsettings.mako @@ -15,8 +15,8 @@ <app-settings :groups="groups" :showing-group="showingGroup"></app-settings> </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} <script type="text/x-template" id="app-settings-template"> <div class="form"> @@ -150,18 +150,19 @@ </script> </%def> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> ThisPageData.groups = ${json.dumps(settings_data)|n} ThisPageData.showingGroup = ${json.dumps(current_group or '')|n} + </script> </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - <script> +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + <script type="text/javascript"> Vue.component('app-settings', { template: '#app-settings-template', @@ -192,3 +193,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 8228f823..c4cbd648 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,5 +1,4 @@ ## -*- coding: utf-8; -*- -<%namespace file="/wutta-components.mako" import="make_wutta_components" /> <%namespace file="/grids/nav.mako" import="grid_index_nav" /> <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" /> <%namespace name="base_meta" file="/base_meta.mako" /> @@ -35,21 +34,17 @@ </head> <body> - <div id="app" style="height: 100%;"> + ${declare_formposter_mixin()} + + ${self.body()} + + <div id="whole-page-app"> <whole-page></whole-page> </div> - ## TODO: this must come before the self.body() call..but why? - ${declare_formposter_mixin()} - - ## content body from derived/child template - ${self.body()} - - ## Vue app - ${self.render_vue_templates()} - ${self.modify_vue_vars()} - ${self.make_vue_components()} - ${self.make_vue_app()} + ${self.render_whole_page_template()} + ${self.make_whole_page_component()} + ${self.make_whole_page_app()} </body> </html> @@ -127,16 +122,16 @@ </%def> <%def name="vuejs()"> - ${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))} - ${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))} + ${h.javascript_link(h.get_liburl(request, 'vue'))} + ${h.javascript_link(h.get_liburl(request, 'vue_resource'))} </%def> <%def name="buefy()"> - ${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))} + ${h.javascript_link(h.get_liburl(request, 'buefy'))} </%def> <%def name="fontawesome()"> - <script defer src="${h.get_liburl(request, 'fontawesome', prefix='tailbone')}"></script> + <script defer src="${h.get_liburl(request, 'fontawesome')}"></script> </%def> <%def name="extra_javascript()"></%def> @@ -158,16 +153,12 @@ <style type="text/css"> .filters .filter-fieldname, .filters .filter-fieldname .button { - % if filter_fieldname_width is not Undefined: min-width: ${filter_fieldname_width}; - % endif justify-content: left; } - % if filter_fieldname_width is not Undefined: .filters .filter-verb { min-width: ${filter_verb_width}; } - % endif </style> </%def> @@ -176,7 +167,7 @@ ${h.stylesheet_link(user_css)} % else: ## upstream Buefy CSS - ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))} + ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} % endif </%def> @@ -186,7 +177,7 @@ <%def name="head_tags()"></%def> -<%def name="render_vue_template_whole_page()"> +<%def name="render_whole_page_template()"> <script type="text/x-template" id="whole-page-template"> <div> <header> @@ -285,7 +276,7 @@ <span class="header-text"> ${index_title} </span> - % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + % if master.creatable and master.show_create_link and master.has_perm('create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" icon-left="plus" @@ -311,7 +302,7 @@ <span class="header-text"> ${h.link_to(instance_title, instance_url)} </span> - % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + % elif master.creatable and master.show_create_link and master.has_perm('create'): % if not request.matched_route.name.endswith('.create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" @@ -632,23 +623,9 @@ % endif <div class="navbar-dropdown"> % if request.is_root: - ${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()} + ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} % elif request.is_admin: - ${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()} + ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} % endif % if messaging_enabled: ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} @@ -656,11 +633,7 @@ % if request.is_root or not request.user.prevent_password_change: ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} % endif - % try: - ## nb. does not exist yet for wuttaweb - ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} - % except: - % endtry + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} ${h.link_to("Logout", url('logout'), class_='navbar-item')} </div> </div> @@ -681,19 +654,19 @@ ## TODO: is there a better way to check if viewing parent? % if parent_instance is Undefined: % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${master.get_action_url('edit', instance)}" + <once-button tag="a" href="${action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> % endif - % 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)}" + % if master.cloneable and master.has_perm('clone'): + <once-button tag="a" href="${action_url('clone', instance)}" icon-left="object-ungroup" text="Clone This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${master.get_action_url('delete', instance)}" + <once-button tag="a" href="${action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -702,7 +675,7 @@ % else: ## viewing row % if instance_deletable and master.has_perm('delete_row'): - <once-button tag="a" href="${master.get_action_url('delete', instance)}" + <once-button tag="a" href="${action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -711,13 +684,13 @@ % endif % elif master and master.editing: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${master.get_action_url('view', instance)}" + <once-button tag="a" href="${action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${master.get_action_url('delete', instance)}" + <once-button tag="a" href="${action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -725,13 +698,13 @@ % endif % elif master and master.deleting: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${master.get_action_url('view', instance)}" + <once-button tag="a" href="${action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${master.get_action_url('edit', instance)}" + <once-button tag="a" href="${action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> @@ -772,8 +745,11 @@ % endif </%def> -<%def name="render_vue_script_whole_page()"> - <script> +<%def name="declare_whole_page_vars()"> + ${page_help.declare_vars()} + ${multi_file_upload.declare_vars()} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} + <script type="text/javascript"> let WholePage = { template: '#whole-page-template', @@ -880,7 +856,7 @@ feedbackMessage: "", % if expose_theme_picker and request.has_perm('common.change_app_theme'): - globalTheme: ${json.dumps(theme or None)|n}, + globalTheme: ${json.dumps(theme)|n}, referrer: location.href, % endif @@ -890,7 +866,7 @@ globalSearchActive: false, globalSearchTerm: '', - globalSearchData: ${json.dumps(global_search_data or [])|n}, + globalSearchData: ${json.dumps(global_search_data)|n}, mountedHooks: [], } @@ -909,6 +885,57 @@ </script> </%def> +<%def name="modify_whole_page_vars()"> + <script type="text/javascript"> + + % if request.user: + FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} + FeedbackFormData.userName = ${json.dumps(str(request.user))|n} + % endif + + </script> +</%def> + +<%def name="finalize_whole_page_vars()"> + ## NOTE: if you override this, must use <script> tags +</%def> + +<%def name="make_whole_page_component()"> + + ${make_grid_filter_components()} + + ${self.declare_whole_page_vars()} + ${self.modify_whole_page_vars()} + ${self.finalize_whole_page_vars()} + + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} + + ${page_help.make_component()} + ${multi_file_upload.make_component()} + + <script type="text/javascript"> + + FeedbackForm.data = function() { return FeedbackFormData } + + Vue.component('feedback-form', FeedbackForm) + + WholePage.data = function() { return WholePageData } + + Vue.component('whole-page', WholePage) + + </script> +</%def> + +<%def name="make_whole_page_app()"> + <script type="text/javascript"> + + new Vue({ + el: '#whole-page-app' + }) + + </script> +</%def> + <%def name="wtfield(form, name, **kwargs)"> <div class="field-wrapper${' error' if form[name].errors else ''}"> <label for="${name}">${form[name].label}</label> @@ -930,88 +957,3 @@ </div> </div> </%def> - -############################## -## vue components + app -############################## - -<%def name="render_vue_templates()"> - ${page_help.declare_vars()} - ${multi_file_upload.declare_vars()} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))} - - ## DEPRECATED; called for back-compat - ${self.render_whole_page_template()} -</%def> - -## DEPRECATED; remains for back-compat -<%def name="render_whole_page_template()"> - ${self.render_vue_template_whole_page()} - ${self.declare_whole_page_vars()} -</%def> - -## DEPRECATED; remains for back-compat -<%def name="declare_whole_page_vars()"> - ${self.render_vue_script_whole_page()} -</%def> - -<%def name="modify_vue_vars()"> - ## DEPRECATED; called for back-compat - ${self.modify_whole_page_vars()} -</%def> - -## DEPRECATED; remains for back-compat -<%def name="modify_whole_page_vars()"> - <script> - - % if request.user: - FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(str(request.user))|n} - % endif - - </script> -</%def> - -<%def name="make_vue_components()"> - ${make_wutta_components()} - ${make_grid_filter_components()} - ${page_help.make_component()} - ${multi_file_upload.make_component()} - <script> - FeedbackForm.data = function() { return FeedbackFormData } - Vue.component('feedback-form', FeedbackForm) - </script> - - ## DEPRECATED; called for back-compat - ${self.finalize_whole_page_vars()} - ${self.make_whole_page_component()} -</%def> - -## DEPRECATED; remains for back-compat -<%def name="make_whole_page_component()"> - <script> - WholePage.data = function() { return WholePageData } - Vue.component('whole-page', WholePage) - </script> -</%def> - -<%def name="make_vue_app()"> - ## DEPRECATED; called for back-compat - ${self.make_whole_page_app()} -</%def> - -## DEPRECATED; remains for back-compat -<%def name="make_whole_page_app()"> - <script> - new Vue({ - el: '#app' - }) - </script> -</%def> - -############################## -## DEPRECATED -############################## - -<%def name="finalize_whole_page_vars()"></%def> diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index b6376448..00cfdfe9 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -1,7 +1,10 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/base_meta.mako" /> -<%def name="app_title()">${app.get_node_title()}</%def> +<%def name="app_title()">${rattail_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()"> <link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" /> @@ -10,3 +13,9 @@ <%def name="header_logo()"> ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")} </%def> + +<%def name="footer()"> + <p class="has-text-centered"> + powered by ${h.link_to("Rattail", url('about'))} + </p> +</%def> diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index bea10a97..209fbb0c 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -43,7 +43,7 @@ <br /> <div class="form-wrapper"> <div class="form"> - ${execute_form.render_vue_tag(ref='executeResultsForm')} + <${execute_form.component} ref="executeResultsForm"></${execute_form.component}> </div> </div> </section> @@ -64,17 +64,10 @@ % endif </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} - % if master.results_executable and master.has_perm('execute_multiple'): - ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} - % endif -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if master.results_refreshable and master.has_perm('refresh'): - <script> + <script type="text/javascript"> TailboneGridData.refreshResultsButtonText = "Refresh Results" TailboneGridData.refreshResultsButtonDisabled = false @@ -88,9 +81,9 @@ </script> % endif % if master.results_executable and master.has_perm('execute_multiple'): - <script> + <script type="text/javascript"> - ${execute_form.vue_component}.methods.submit = function() { + ${execute_form.component_studly}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } @@ -125,9 +118,25 @@ % endif </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} % if master.results_executable and master.has_perm('execute_multiple'): - ${execute_form.render_vue_finalize()} + <script type="text/javascript"> + + ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } + + Vue.component('${execute_form.component}', ${execute_form.component_studly}) + + </script> % endif </%def> + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + % if master.results_executable and master.has_perm('execute_multiple'): + ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + % endif +</%def> + + +${parent.body()} diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index cddaa2c5..7e4795a8 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -147,7 +147,7 @@ <script type="text/javascript"> - let ${form.vue_component} = { + let ${form.component_studly} = { template: '#${form.component}-template', mixins: [SimpleRequestMixin], @@ -278,7 +278,7 @@ }, } - let ${form.vue_component}Data = { + let ${form.component_studly}Data = { submitting: false, productUPC: null, @@ -297,9 +297,14 @@ </script> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + ThisPageData.toggleCompleteSubmitting = false + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako index 5ecabd4d..0da755aa 100644 --- a/tailbone/templates/batch/pos/view.mako +++ b/tailbone/templates/batch/pos/view.mako @@ -1,9 +1,13 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/view.mako" /> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - ${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.taxesData = ${json.dumps(taxes_data)|n} + </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako index 4f91cb02..0d57053e 100644 --- a/tailbone/templates/batch/vendorcatalog/configure.mako +++ b/tailbone/templates/batch/vendorcatalog/configure.mako @@ -39,9 +39,14 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n} + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index d9d62bd1..d25c8f16 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -1,16 +1,16 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> - ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n} + ${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n} - ${form.vue_component}Data.vendorName = null - ${form.vue_component}Data.vendorNameReplacement = null + ${form.component_studly}Data.vendorName = null + ${form.component_studly}Data.vendorNameReplacement = null - ${form.vue_component}.watch.field_model_parser_key = function(val) { + ${form.component_studly}.watch.field_model_parser_key = function(val) { let parser = this.parsers[val] if (parser.vendor_uuid) { if (this.field_model_vendor_uuid != parser.vendor_uuid) { @@ -24,11 +24,11 @@ } } - ${form.vue_component}.methods.vendorLabelChanging = function(label) { + ${form.component_studly}.methods.vendorLabelChanging = function(label) { this.vendorNameReplacement = label } - ${form.vue_component}.methods.vendorChanged = function(uuid) { + ${form.component_studly}.methods.vendorChanged = function(uuid) { if (uuid) { this.vendorName = this.vendorNameReplacement this.vendorNameReplacement = null @@ -37,3 +37,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 7c81ab0e..5e3328d9 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -85,11 +85,13 @@ <div style="display: flex; flex-direction: column; gap: 0.5rem;"> % if batch.executed: <p> + Batch was executed ${h.pretty_datetime(request.rattail_config, batch.executed)} by ${batch.executed_by} </p> % elif master.handler.executable(batch): % if master.has_perm('execute'): + <p>Batch has not yet been executed.</p> <b-button type="is-primary" % if not execute_enabled: disabled @@ -119,7 +121,8 @@ <div class="markdown"> ${execution_described|n} </div> - ${execute_form.render_vue_tag(ref='executeBatchForm')} + <${execute_form.component} ref="executeBatchForm"> + </${execute_form.component}> </section> <footer class="modal-card-foot"> @@ -148,6 +151,12 @@ </nav> </%def> +<%def name="render_form_template()"> + ## TODO: should use self.render_form_buttons() + ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} + ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n} +</%def> + <%def name="render_this_page()"> ${parent.render_this_page()} @@ -167,7 +176,8 @@ Please be certain to use the right one! </p> <br /> - ${upload_worksheet_form.render_vue_tag(ref='uploadForm')} + <${upload_worksheet_form.component} ref="uploadForm"> + </${upload_worksheet_form.component}> </section> <footer class="modal-card-foot"> @@ -189,6 +199,16 @@ </%def> +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): + ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} + % endif + % if master.handler.executable(batch) and master.has_perm('execute'): + ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + % endif +</%def> + <%def name="render_form()"> <div class="form"> <${form.component} @show-upload="showUploadDialog = true"> @@ -249,27 +269,9 @@ % endif </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} - % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - ${upload_worksheet_form.render_vue_template(buttons=False, form_kwargs={'ref': 'actualUploadForm'})} - % endif - % if master.handler.executable(batch) and master.has_perm('execute'): - ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} - % endif -</%def> - -## DEPRECATED; remains for back-compat -## nb. this is called by parent template, /form.mako -<%def name="render_form_template()"> - ## TODO: should use self.render_form_buttons() - ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} - ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n} -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n} @@ -285,7 +287,7 @@ } % if not batch.executed and master.has_perm('edit'): - ${form.vue_component}Data.togglingBatchComplete = false + ${form.component_studly}Data.togglingBatchComplete = false % endif % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): @@ -306,7 +308,7 @@ form.submit() } - ${upload_worksheet_form.vue_component}.methods.submit = function() { + ${upload_worksheet_form.component_studly}.methods.submit = function() { this.$refs.actualUploadForm.submit() } @@ -321,7 +323,7 @@ this.$refs.executeBatchForm.submit() } - ${execute_form.vue_component}.methods.submit = function() { + ${execute_form.component_studly}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } @@ -329,9 +331,9 @@ % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): - ${rows_grid.vue_component}Data.deleteResultsShowDialog = false + ${rows_grid.component_studly}Data.deleteResultsShowDialog = false - ${rows_grid.vue_component}.methods.deleteResultsInit = function() { + ${rows_grid.component_studly}.methods.deleteResultsInit = function() { this.deleteResultsShowDialog = true } @@ -340,12 +342,28 @@ </script> </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - ${upload_worksheet_form.render_vue_finalize()} + <script type="text/javascript"> + + ## UploadForm + ${upload_worksheet_form.component_studly}.data = function() { return ${upload_worksheet_form.component_studly}Data } + Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.component_studly}) + + </script> % endif + % if execute_enabled and master.has_perm('execute'): - ${execute_form.render_vue_finalize()} + <script type="text/javascript"> + + ## ExecuteForm + ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } + Vue.component('${execute_form.component}', ${execute_form.component_studly}) + + </script> % endif </%def> + + +${parent.body()} diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako index c7f46d21..c0200912 100644 --- a/tailbone/templates/configure-menus.mako +++ b/tailbone/templates/configure-menus.mako @@ -208,9 +208,9 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n} @@ -443,3 +443,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index e6b128fc..f33779c8 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -92,7 +92,7 @@ <b-select name="${tmpl['setting_file']}" v-model="inputFileTemplateSettings['${tmpl['setting_file']}']" @input="settingsNeedSaved = true"> - <option value="">-new-</option> + <option :value="null">-new-</option> <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']" :key="option" :value="option"> @@ -104,40 +104,22 @@ <b-field label="Upload" v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']"> - % if request.use_oruga: - <o-field class="file"> - <o-upload name="${tmpl['setting_file']}.upload" - v-model="inputFileTemplateUploads['${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="inputFileTemplateUploads['${tmpl['key']}']"> - {{ inputFileTemplateUploads['${tmpl['key']}'].name }} - </span> - </o-upload> - </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 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> </b-field> @@ -161,85 +143,6 @@ </div> </%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="page_content()"> @@ -280,14 +183,15 @@ <b-button @click="purgeSettingsShowDialog = false"> Cancel </b-button> - ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})} + ${h.form(request.current_route_url())} ${h.csrf_token(request)} ${h.hidden('remove_settings', 'true')} <b-button type="is-danger" native-type="submit" :disabled="purgingSettings" icon-pack="fas" - icon-left="trash"> + icon-left="trash" + @click="purgingSettings = true"> {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }} </b-button> ${h.end_form()} @@ -301,42 +205,62 @@ ${h.end_form()} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> % if simple_settings is not Undefined: ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} % 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.purgingSettings = false ThisPageData.settingsNeedSaved = false ThisPageData.undoChanges = false ThisPageData.savingSettings = false - ThisPageData.validators = [] ThisPage.methods.purgeSettingsInit = function() { this.purgeSettingsShowDialog = true } - ThisPage.methods.validateSettings = function() {} + % if input_file_template_settings is not Undefined: + 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.saveSettings = function() { + ThisPage.methods.validateSettings = function() { let msg - // nb. this is the future - for (let validator of this.validators) { - msg = validator.call(this) + % if input_file_template_settings is not Undefined: + msg = this.validateInputFileTemplateSettings() if (msg) { - alert(msg) - return + return msg } - } + % endif + } - // nb. legacy method - msg = this.validateSettings() + ThisPage.methods.saveSettings = function() { + let msg = this.validateSettings() if (msg) { alert(msg) return @@ -367,65 +291,8 @@ 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> </%def> + + +${parent.body()} diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index 1a6dca8b..e68f4543 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -88,9 +88,9 @@ </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPage.methods.getLabelForKey = function(key) { switch (key) { @@ -111,3 +111,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/customers/pending/view.mako b/tailbone/templates/customers/pending/view.mako index 1cea9d1f..e9e54c99 100644 --- a/tailbone/templates/customers/pending/view.mako +++ b/tailbone/templates/customers/pending/view.mako @@ -106,9 +106,9 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.resolvePersonShowDialog = false ThisPageData.resolvePersonUUID = null @@ -139,3 +139,5 @@ </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index 490e4757..8b07bdb3 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -16,15 +16,15 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> % if expose_shoppers: - ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n} + ${form.component_studly}Data.shoppers = ${json.dumps(shoppers_data)|n} % endif % if expose_people: - ${form.vue_component}Data.peopleData = ${json.dumps(people_data)|n} + ${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n} % endif ThisPage.methods.detachPerson = function(url) { @@ -36,3 +36,5 @@ </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 382a121f..63505422 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -47,9 +47,10 @@ </div> </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} ${product_lookup.tailbone_product_lookup_template()} + <script type="text/x-template" id="customer-order-creator-template"> <div> @@ -1264,7 +1265,12 @@ </div> </script> - <script> +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + ${product_lookup.tailbone_product_lookup_component()} + <script type="text/javascript"> const CustomerOrderCreator = { template: '#customer-order-creator-template', @@ -2400,7 +2406,5 @@ </script> </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - ${product_lookup.tailbone_product_lookup_component()} -</%def> + +${parent.body()} diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 4cc92bbf..f7a6dd0a 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -291,11 +291,11 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> - ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n} + ${form.component_studly}Data.eventsData = ${json.dumps(events_data)|n} % if master.has_perm('confirm_price'): @@ -392,9 +392,9 @@ this.$refs.changeStatusForm.submit() } - ${form.vue_component}Data.changeFlaggedSubmitting = false + ${form.component_studly}Data.changeFlaggedSubmitting = false - ${form.vue_component}.methods.changeFlaggedSubmit = function() { + ${form.component_studly}.methods.changeFlaggedSubmit = function() { this.changeFlaggedSubmitting = true } @@ -448,3 +448,5 @@ </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index 86f5c121..6d171619 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -26,9 +26,9 @@ </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> % if request.has_perm('datasync.restart'): TailboneGridData.restartDatasyncFormSubmitting = false @@ -50,3 +50,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 2e444fb5..7922d189 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -83,8 +83,8 @@ </b-notification> <b-field> - <b-checkbox name="rattail.datasync.use_profile_settings" - v-model="simpleSettings['rattail.datasync.use_profile_settings']" + <b-checkbox name="use_profile_settings" + v-model="useProfileSettings" native-value="true" @input="settingsNeedSaved = true"> Use these Settings to configure watchers and consumers @@ -99,7 +99,7 @@ </div> <div class="level-right"> <div class="level-item" - v-show="simpleSettings['rattail.datasync.use_profile_settings']"> + v-show="useProfileSettings"> <b-button type="is-primary" @click="newProfile()" icon-pack="fas" @@ -162,7 +162,7 @@ </${b}-table-column> <${b}-table-column label="Actions" v-slot="props" - v-if="simpleSettings['rattail.datasync.use_profile_settings']"> + v-if="useProfileSettings"> <a href="#" class="grid-action" @click.prevent="editProfile(props.row)"> @@ -580,27 +580,18 @@ <b-field label="Supervisor Process Name" message="This should be the complete name, including group - e.g. poser:poser_datasync" expanded> - <b-input name="rattail.datasync.supervisor_process_name" - v-model="simpleSettings['rattail.datasync.supervisor_process_name']" + <b-input name="supervisor_process_name" + v-model="supervisorProcessName" @input="settingsNeedSaved = true" expanded> </b-input> </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" 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> - <b-input name="tailbone.datasync.restart" - v-model="simpleSettings['tailbone.datasync.restart']" + <b-input name="restart_command" + v-model="restartCommand" @input="settingsNeedSaved = true" expanded> </b-input> @@ -608,13 +599,14 @@ </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.showConfigFilesNote = false ThisPageData.profilesData = ${json.dumps(profiles_data)|n} ThisPageData.showDisabledProfiles = false + ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n} ThisPageData.editProfileShowDialog = false ThisPageData.editingProfile = null @@ -639,6 +631,9 @@ ThisPageData.editingConsumerRunas = null ThisPageData.editingConsumerEnabled = true + ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n} + ThisPageData.restartCommand = ${json.dumps(restart_command)|n} + ThisPage.computed.updateConsumerDisabled = function() { if (!this.editingConsumerKey) { return true @@ -987,3 +982,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index e14686f8..c782dec6 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -115,9 +115,8 @@ </${b}-table> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + <script type="text/javascript"> ThisPageData.processInfo = ${json.dumps(process_info)|n} @@ -172,3 +171,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt index 2121f01d..f78c0b85 100644 --- a/tailbone/templates/deform/checked_password.pt +++ b/tailbone/templates/deform/checked_password.pt @@ -1,7 +1,6 @@ <div i18n:domain="deform" tal:omit-tag="" tal:define="oid oid|field.oid; name name|field.name; - vmodel vmodel|'field_model_' + name; css_class css_class|field.widget.css_class; style style|field.widget.style;"> @@ -9,7 +8,7 @@ ${field.start_mapping()} <b-input type="password" name="${name}" - v-model="${vmodel}" + value="${field.widget.redisplay and cstruct or ''}" tal:attributes="class string: form-control ${css_class or ''}; style style; attributes|field.widget.attributes|{};" @@ -19,6 +18,7 @@ </b-input> <b-input type="password" name="${name}-confirm" + value="${field.widget.redisplay and confirm or ''}" tal:attributes="class string: form-control ${css_class or ''}; style style; confirm_attributes|field.widget.confirm_attributes|{};" diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako index c5c39cbb..442f045f 100644 --- a/tailbone/templates/departments/view.mako +++ b/tailbone/templates/departments/view.mako @@ -1,9 +1,13 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.employeesData = ${json.dumps(employees_data)|n} + </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index e3a4d5dc..c9c8ea88 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -6,12 +6,12 @@ <%def name="render_form_buttons()"></%def> <%def name="render_form_template()"> - ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n} + ${form.render_deform(buttons=capture(self.render_form_buttons))|n} </%def> <%def name="render_form()"> <div class="form"> - ${form.render_vue_tag()} + ${form.render_vuejs_component()} </div> </%def> @@ -90,15 +90,15 @@ <%def name="before_object_helpers()"></%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_this_page_template()"> % if form is not Undefined: ${self.render_form_template()} % endif + ${parent.render_this_page_template()} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if main_form_collapsible: <script> ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'} @@ -106,9 +106,18 @@ % endif </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} +<%def name="finalize_this_page_vars()"> + ${parent.finalize_this_page_vars()} % if form is not Undefined: - ${form.render_vue_finalize()} + <script type="text/javascript"> + + ${form.component_studly}.data = function() { return ${form.component_studly}Data } + + Vue.component('${form.component}', ${form.component_studly}) + + </script> % endif </%def> + + +${parent.body()} diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako index d566a467..ab9c720d 100644 --- a/tailbone/templates/formposter.mako +++ b/tailbone/templates/formposter.mako @@ -39,7 +39,7 @@ simplePOST(action, params, success, failure) { - let csrftoken = ${json.dumps(h.get_csrf_token(request))|n} + let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} let headers = { '${csrf_header_name}': csrftoken, diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 2100b460..00cf2c50 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -1,19 +1,19 @@ ## -*- coding: utf-8; -*- -<% request.register_component(form.vue_tagname, form.vue_component) %> +<% request.register_component(form.component, form.component_studly) %> -<script type="text/x-template" id="${form.vue_tagname}-template"> +<script type="text/x-template" id="${form.component}-template"> <div> % if not form.readonly: - ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **(form_kwargs or {}))} + ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)} ${h.csrf_token(request)} % endif <section> % if form_body is not Undefined and form_body: ${form_body|n} - % elif getattr(form, 'grouping', None): + % elif form.grouping: % for group in form.grouping: <nav class="panel"> <p class="panel-heading">${group}</p> @@ -27,8 +27,8 @@ </nav> % endfor % else: - % for fieldname in form.fields: - ${form.render_vue_field(fieldname, session=session)} + % for field in form.fields: + ${form.render_field_complete(field)} % endfor % endif </section> @@ -54,20 +54,20 @@ <input type="reset" value="Reset" class="button" /> % endif ## TODO: deprecate / remove the latter option here - % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: + % if form.auto_disable_save or form.auto_disable: <b-button type="is-primary" native-type="submit" - :disabled="${form.vue_component}Submitting" + :disabled="${form.component_studly}Submitting" icon-pack="fas" - icon-left="${form.button_icon_submit}"> - {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }} + icon-left="save"> + {{ ${form.component_studly}ButtonText }} </b-button> % else: <b-button type="is-primary" native-type="submit" icon-pack="fas" icon-left="save"> - ${form.button_label_submit} + ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))} </b-button> % endif </div> @@ -122,8 +122,8 @@ <script type="text/javascript"> - let ${form.vue_component} = { - template: '#${form.vue_tagname}-template', + let ${form.component_studly} = { + template: '#${form.component}-template', mixins: [FormPosterMixin], components: {}, props: { @@ -136,9 +136,10 @@ methods: { ## TODO: deprecate / remove the latter option here - % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: - submit${form.vue_component}() { - this.${form.vue_component}Submitting = true + % if form.auto_disable_save or form.auto_disable: + submit${form.component_studly}() { + this.${form.component_studly}Submitting = true + this.${form.component_studly}ButtonText = "Working, please wait..." }, % endif @@ -177,10 +178,10 @@ } } - let ${form.vue_component}Data = { + let ${form.component_studly}Data = { ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, + csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, % if can_edit_help: fieldLabels: ${json.dumps(field_labels)|n}, @@ -197,14 +198,16 @@ % if not form.readonly: % for field in form.fields: % if field in dform: - field_model_${field}: ${json.dumps(form.get_vue_field_value(field))|n}, + <% field = dform[field] %> + field_model_${field.name}: ${form.get_vuejs_model_value(field)|n}, % endif % endfor % endif ## TODO: deprecate / remove the latter option here - % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: - ${form.vue_component}Submitting: false, + % if form.auto_disable_save or form.auto_disable: + ${form.component_studly}Submitting: false, + ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, % endif } diff --git a/tailbone/templates/forms/vue_template.mako b/tailbone/templates/forms/vue_template.mako deleted file mode 100644 index ac096f67..00000000 --- a/tailbone/templates/forms/vue_template.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/forms/deform.mako" /> -${parent.body()} diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index 0f2a9f7b..18a26f58 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -276,9 +276,9 @@ </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.featureType = ${json.dumps(feature_type)|n} ThisPageData.resultGenerated = ${json.dumps(bool(result))|n} @@ -385,3 +385,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index da9f2aae..632193b5 100644 --- a/tailbone/templates/grids/b-table.mako +++ b/tailbone/templates/grids/b-table.mako @@ -53,11 +53,11 @@ </${b}-table-column> % endfor - % if grid.actions: + % if grid.main_actions or grid.more_actions: <${b}-table-column field="actions" label="Actions" v-slot="props"> - % for action in grid.actions: + % for action in grid.main_actions: <a :href="props.row._action_url_${action.key}" % if action.link_class: class="${action.link_class}" @@ -68,7 +68,12 @@ @click.prevent="${action.click_handler}" % endif > - ${action.render_icon_and_label()} + % if request.use_oruga: + <o-icon icon="${action.icon}" /> + % else: + <i class="fas fa-${action.icon}"></i> + % endif + ${action.label} </a> % endfor diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 60f9a3b8..a0f927d3 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -1,79 +1,17 @@ ## -*- coding: utf-8; -*- -<% request.register_component(grid.vue_tagname, grid.vue_component) %> +<% request.register_component(grid.component, grid.component_studly) %> -<script type="text/x-template" id="${grid.vue_tagname}-template"> +<script type="text/x-template" id="${grid.component}-template"> <div> <div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;"> <div style="display: flex; flex-direction: column; justify-content: end;"> <div class="filters"> - % if getattr(grid, 'filterable', False): - <form 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> + % if grid.filterable: + ## TODO: stop using |n filter + ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n} % endif </div> </div> @@ -117,7 +55,7 @@ :checkable="checkable" - % if getattr(grid, 'checkboxes', False): + % if grid.checkboxes: % if request.use_oruga: v-model:checked-rows="checkedRows" % else: @@ -128,64 +66,51 @@ % endif % endif - % if getattr(grid, 'check_handler', None): + % if grid.check_handler: @check="${grid.check_handler}" % endif - % if getattr(grid, 'check_all_handler', None): + % if grid.check_all_handler: @check-all="${grid.check_all_handler}" % endif - % if hasattr(grid, 'checkable'): % if isinstance(grid.checkable, str): :is-row-checkable="${grid.row_checkable}" % elif grid.checkable: :is-row-checkable="row => row._checkable" % endif - % endif - ## sorting % if grid.sortable: - ## nb. buefy/oruga only support *one* default sorter - :default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null" - % if grid.sort_on_backend: - backend-sorting - @sort="onSort" - % endif - % if grid.sort_multiple: - % if grid.sort_on_backend: - ## TODO: there is a bug (?) which prevents the arrow - ## from displaying for simple default single-column sort, - ## when multi-column sort is allowed for the table. for - ## now we work around that by waiting until mount to - ## enable the multi-column support. see also - ## https://github.com/buefy/buefy/issues/2584 - :sort-multiple="allowMultiSort" - :sort-multiple-data="sortingPriority" - @sorting-priority-removed="sortingPriorityRemoved" - % else: - sort-multiple - % endif - ## nb. user must ctrl-click column header for multi-sort - sort-multiple-key="ctrlKey" - % endif + backend-sorting + @sort="onSort" + @sorting-priority-removed="sortingPriorityRemoved" + + ## TODO: there is a bug (?) which prevents the arrow from + ## displaying for simple default single-column sort. so to + ## work around that, we *disable* multi-sort until the + ## component is mounted. seems to work for now..see also + ## https://github.com/buefy/buefy/issues/2584 + :sort-multiple="allowMultiSort" + + ## nb. specify default sort only if single-column + :default-sort="backendSorters.length == 1 ? [backendSorters[0].field, backendSorters[0].order] : null" + + ## nb. otherwise there may be default multi-column sort + :sort-multiple-data="sortingPriority" + + ## user must ctrl-click column header to do multi-sort + sort-multiple-key="ctrlKey" % endif - % if getattr(grid, 'click_handlers', None): + % if grid.click_handlers: @cellclick="cellClick" % endif - ## paging - % if grid.paginated: - paginated - pagination-size="${'small' if request.use_oruga else 'is-small'}" - :per-page="perPage" - :current-page="currentPage" - @page-change="onPageChange" - % if grid.paginate_on_backend: - backend-pagination - :total="pagerStats.item_count" - % endif - % endif + :paginated="paginated" + :per-page="perPage" + :current-page="currentPage" + backend-pagination + :total="total" + @page-change="onPageChange" ## TODO: should let grid (or master view) decide how to set these? icon-pack="fas" @@ -194,15 +119,17 @@ :hoverable="true" :narrowed="true"> - % for column in grid.get_vue_columns(): + % for column in grid_columns: <${b}-table-column field="${column['field']}" label="${column['label']}" v-slot="props" - :sortable="${json.dumps(column.get('sortable', False))|n}" - :searchable="${json.dumps(column.get('searchable', False))|n}" + :sortable="${json.dumps(column['sortable'])}" + % if grid.is_searchable(column['field']): + searchable + % endif cell-class="c_${column['field']}" - :visible="${json.dumps(column.get('visible', True))}"> - % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers: + :visible="${json.dumps(column['visible'])}"> + % if column['field'] in grid.raw_renderers: ${grid.raw_renderers[column['field']]()} % elif grid.is_linked(column['field']): <a :href="props.row._action_url_view" @@ -217,24 +144,30 @@ </${b}-table-column> % endfor - % if grid.actions: + % if grid.main_actions or grid.more_actions: <${b}-table-column field="actions" label="Actions" v-slot="props"> ## TODO: we do not currently differentiate for "main vs. more" ## here, but ideally we would tuck "more" away in a drawer etc. - % for action in grid.actions: + % for action in grid.main_actions + grid.more_actions: <a v-if="props.row._action_url_${action.key}" :href="props.row._action_url_${action.key}" class="grid-action${' has-text-danger' if action.key == 'delete' else ''} ${action.link_class or ''}" - % if getattr(action, 'click_handler', None): + % if action.click_handler: @click.prevent="${action.click_handler}" % endif - % if getattr(action, 'target', None): + % if action.target: target="${action.target}" % endif > - ${action.render_icon_and_label()} + % if request.use_oruga: + <o-icon icon="${action.icon}" /> + <span>${action.render_label()|n}</span> + % else: + ${action.render_icon()|n} + ${action.render_label()|n} + % endif </a> % endfor @@ -259,7 +192,7 @@ <template #footer> <div style="display: flex; justify-content: space-between;"> - % if getattr(grid, 'expose_direct_link', False): + % if grid.expose_direct_link: <b-button type="is-primary" size="is-small" @click="copyDirectLink()" @@ -274,14 +207,13 @@ <div></div> % endif - % if grid.paginated: - <div v-if="pagerStats.first_item" + % if grid.pageable: + <div v-if="firstItem" style="display: flex; gap: 0.5rem; align-items: center;"> <span> showing - {{ renderNumber(pagerStats.first_item) }} - - {{ renderNumber(pagerStats.last_item) }} - of {{ renderNumber(pagerStats.item_count) }} results; + {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} + of {{ total.toLocaleString('en') }} results; </span> <b-select v-model="perPage" size="is-small" @@ -302,7 +234,7 @@ </${b}-table> ## dummy input field needed for sharing links on *insecure* sites - % if getattr(request, 'scheme', None) == 'http': + % if request.scheme == 'http': <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input> % endif @@ -311,72 +243,65 @@ <script type="text/javascript"> - const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n} - let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data + let ${grid.component_studly}CurrentData = ${json.dumps(grid_data['data'])|n} - let ${grid.vue_component}Data = { + let ${grid.component_studly}Data = { loading: false, - ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n}, - - ## nb. this tracks whether grid.fetchFirstData() happened - fetchedFirstData: false, + ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n}, savingDefaults: false, - data: ${grid.vue_component}CurrentData, - rowStatusMap: ${json.dumps(grid_data['row_status_map'] if grid_data is not Undefined else {})|n}, + data: ${grid.component_studly}CurrentData, + rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n}, - checkable: ${json.dumps(getattr(grid, 'checkboxes', False))|n}, - % if getattr(grid, 'checkboxes', False): + checkable: ${json.dumps(grid.checkboxes)|n}, + % if grid.checkboxes: checkedRows: ${grid_data['checked_rows_code']|n}, % endif - ## paging - % if grid.paginated: - pageSizeOptions: ${json.dumps(grid.pagesize_options)|n}, - perPage: ${json.dumps(grid.pagesize)|n}, - currentPage: ${json.dumps(grid.page)|n}, - % if grid.paginate_on_backend: - pagerStats: ${json.dumps(grid.get_vue_pager_stats())|n}, - % endif - % endif + paginated: ${json.dumps(grid.pageable)|n}, + total: ${len(grid_data['data']) if static_data else grid_data['total_items']}, + perPage: ${json.dumps(grid.pagesize if grid.pageable else None)|n}, + currentPage: ${json.dumps(grid.page if grid.pageable else None)|n}, + firstItem: ${json.dumps(grid_data['first_item'] if grid.pageable else None)|n}, + lastItem: ${json.dumps(grid_data['last_item'] if grid.pageable else None)|n}, - ## sorting % if grid.sortable: - sorters: ${json.dumps(grid.get_vue_active_sorters())|n}, - % if grid.sort_multiple: - % if grid.sort_on_backend: - ## TODO: there is a bug (?) which prevents the arrow - ## from displaying for simple default single-column sort, - ## when multi-column sort is allowed for the table. for - ## now we work around that by waiting until mount to - ## enable the multi-column support. see also - ## https://github.com/buefy/buefy/issues/2584 - allowMultiSort: false, - ## nb. this should be empty when current sort is single-column - % if len(grid.active_sorters) > 1: - sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n}, - % else: - sortingPriority: [], - % endif - % endif + + ## TODO: there is a bug (?) which prevents the arrow from + ## displaying for simple default single-column sort. so to + ## work around that, we *disable* multi-sort until the + ## component is mounted. seems to work for now..see also + ## https://github.com/buefy/buefy/issues/2584 + allowMultiSort: false, + + ## nb. this contains all truly active sorters + backendSorters: ${json.dumps(grid.active_sorters)|n}, + + ## nb. whereas this will only contain multi-column sorters, + ## but will be *empty* for single-column sorting + % if len(grid.active_sorters) > 1: + sortingPriority: ${json.dumps(grid.active_sorters)|n}, + % else: + sortingPriority: [], % endif + % endif ## filterable: ${json.dumps(grid.filterable)|n}, - filters: ${json.dumps(filters_data if getattr(grid, 'filterable', False) else None)|n}, - filtersSequence: ${json.dumps(filters_sequence if getattr(grid, 'filterable', False) else None)|n}, + filters: ${json.dumps(filters_data if grid.filterable else None)|n}, + filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n}, addFilterTerm: '', addFilterShow: false, ## dummy input value needed for sharing links on *insecure* sites - % if getattr(request, 'scheme', None) == 'http': + % if request.scheme == 'http': shareLink: null, % endif } - let ${grid.vue_component} = { - template: '#${grid.vue_tagname}-template', + let ${grid.component_studly} = { + template: '#${grid.component}-template', mixins: [FormPosterMixin], @@ -386,32 +311,6 @@ computed: { - ## TODO: this should be temporary? but anyway 'total' is - ## still referenced in other places, e.g. "delete results" - % if grid.paginated: - total() { return this.pagerStats.item_count }, - % endif - - % if not grid.paginate_on_backend: - - pagerStats() { - const data = this.visibleData - let last = this.currentPage * this.perPage - let first = last - this.perPage + 1 - if (last > data.length) { - last = data.length - } - return { - 'item_count': data.length, - 'items_per_page': this.perPage, - 'page': this.currentPage, - 'first_item': first, - 'last_item': last, - } - }, - - % endif - addFilterChoices() { // nb. this returns all choices available for "Add Filter" operation @@ -459,32 +358,21 @@ directLink() { let params = new URLSearchParams(this.getAllParams()) - return `${request.path_url}?${'$'}{params}` + return `${request.current_route_url(_query=None)}?${'$'}{params}` }, }, - % if grid.sortable and grid.sort_multiple and grid.sort_on_backend: - - ## TODO: there is a bug (?) which prevents the arrow - ## from displaying for simple default single-column sort, - ## when multi-column sort is allowed for the table. for - ## now we work around that by waiting until mount to - ## enable the multi-column support. see also - ## https://github.com/buefy/buefy/issues/2584 - mounted() { - this.allowMultiSort = true - }, - - % endif + mounted() { + ## TODO: there is a bug (?) which prevents the arrow from + ## displaying for simple default single-column sort. so to + ## work around that, we *disable* multi-sort until the + ## component is mounted. seems to work for now..see also + ## https://github.com/buefy/buefy/issues/2584 + this.allowMultiSort = true + }, methods: { - renderNumber(value) { - if (value != undefined) { - return value.toLocaleString('en') - } - }, - formatAddFilterItem(filtr) { if (!filtr.key) { filtr = this.filters[filtr] @@ -492,7 +380,7 @@ return filtr.label || filtr.key }, - % if getattr(grid, 'click_handlers', None): + % if grid.click_handlers: cellClick(row, column, rowIndex, columnIndex) { % for key in grid.click_handlers: if (column._props.field == '${key}') { @@ -548,18 +436,17 @@ }, getBasicParams() { - const params = { - % if grid.paginated and grid.paginate_on_backend: - pagesize: this.perPage, - page: this.currentPage, - % endif - } - % if grid.sortable and grid.sort_on_backend: - for (let i = 1; i <= this.sorters.length; i++) { - params['sort'+i+'key'] = this.sorters[i-1].field - params['sort'+i+'dir'] = this.sorters[i-1].order + let params = {} + % if grid.sortable: + for (let i = 1; i <= this.backendSorters.length; i++) { + params['sort'+i+'key'] = this.backendSorters[i-1].field + params['sort'+i+'dir'] = this.backendSorters[i-1].order } % endif + % if grid.pageable: + params.pagesize = this.perPage + params.page = this.currentPage + % endif return params }, @@ -583,17 +470,6 @@ ...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, ## so now i am too. knowing nothing at all of if/how this is ## supposed to improve anything. we shall see i guess @@ -610,23 +486,23 @@ params = params.toString() this.loading = true - this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => { - if (!response.data.error) { - ${grid.vue_component}CurrentData = response.data.data - this.data = ${grid.vue_component}CurrentData - % if grid.paginated and grid.paginate_on_backend: - this.pagerStats = response.data.pager_stats - % endif - this.rowStatusMap = response.data.row_status_map || {} + this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { + if (!data.error) { + ${grid.component_studly}CurrentData = data.data + this.data = ${grid.component_studly}CurrentData + this.rowStatusMap = data.row_status_map + this.total = data.total_items + this.firstItem = data.first_item + this.lastItem = data.last_item this.loading = false this.savingDefaults = false - this.checkedRows = this.locateCheckedRows(response.data.checked_rows || []) + this.checkedRows = this.locateCheckedRows(data.checked_rows) if (success) { success() } } else { this.$buefy.toast.open({ - message: response.data.error, + message: data.error, type: 'is-danger', duration: 2000, // 4 seconds }) @@ -638,11 +514,8 @@ } }) .catch((error) => { - ${grid.vue_component}CurrentData = [] this.data = [] - % if grid.paginated and grid.paginate_on_backend: - this.pagerStats = {} - % endif + this.total = 0 this.loading = false this.savingDefaults = false if (failure) { @@ -681,72 +554,55 @@ }) }, - % if grid.sortable and grid.sort_on_backend: + onSort(field, order, event) { - onSort(field, order, event) { + // nb. buefy passes field name, oruga passes object + if (field.field) { + field = field.field + } - ## nb. buefy passes field name; oruga passes field object - % if request.use_oruga: - field = field.field - % endif + if (event.ctrlKey) { - % if grid.sort_multiple: + // engage or enhance multi-column sorting + let sorter = this.backendSorters.filter(i => i.field === field)[0] + if (sorter) { + sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' + } else { + this.backendSorters.push({field, order}) + } + this.sortingPriority = this.backendSorters - // did user ctrl-click the column header? - if (event.ctrlKey) { - - // toggle direction for existing, or add new sorter - const sorter = this.sorters.filter(s => s.field === field)[0] - if (sorter) { - sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' - } else { - this.sorters.push({field, order}) - } - - // apply multi-column sorting - this.sortingPriority = this.sorters - - } else { - - % endif + } else { // sort by single column only - this.sorters = [{field, order}] + this.backendSorters = [{field, order}] + this.sortingPriority = [] + } - % if grid.sort_multiple: - // multi-column sort not engaged - this.sortingPriority = [] - } - % endif + // always reset to first page when changing sort options + // TODO: i mean..right? would we ever not want that? + this.currentPage = 1 + this.loadAsyncData() + }, - // nb. always reset to first page when sorting changes - this.currentPage = 1 - this.loadAsyncData() - }, + sortingPriorityRemoved(field) { - % if grid.sort_multiple: + // prune field from active sorters + this.backendSorters = this.backendSorters.filter( + (sorter) => sorter.field !== field) - sortingPriorityRemoved(field) { + // nb. must keep active sorter list "as-is" even if + // there is only one sorter; buefy seems to expect it + this.sortingPriority = this.backendSorters - // prune from active sorters - this.sorters = this.sorters.filter(s => s.field !== field) - - // nb. even though we might have just one sorter - // now, we are still technically in multi-sort mode - this.sortingPriority = this.sorters - - this.loadAsyncData() - }, - - % endif - - % endif + this.loadAsyncData() + }, resetView() { this.loading = true // use current url proper, plus reset param - let url = '?reset-view=true' + let url = '?reset-to-default-filters=true' // add current hash, to preserve that in redirect if (location.hash) { @@ -920,7 +776,7 @@ } else { this.checkedRows.push(row) } - % if getattr(grid, 'check_handler', None): + % if grid.check_handler: this.${grid.check_handler}(this.checkedRows, row) % endif }, diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako new file mode 100644 index 00000000..9a80b911 --- /dev/null +++ b/tailbone/templates/grids/filters.mako @@ -0,0 +1,67 @@ +## -*- 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> diff --git a/tailbone/templates/grids/vue_template.mako b/tailbone/templates/grids/vue_template.mako deleted file mode 100644 index 625f046b..00000000 --- a/tailbone/templates/grids/vue_template.mako +++ /dev/null @@ -1,3 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/grids/complete.mako" /> -${parent.body()} diff --git a/tailbone/templates/home.mako b/tailbone/templates/home.mako index 54e44d57..e4f7d072 100644 --- a/tailbone/templates/home.mako +++ b/tailbone/templates/home.mako @@ -1,7 +1,33 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/home.mako" /> +<%inherit file="/page.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()"> ${self.page_content()} </%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()} diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index 2445341d..0396745a 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -144,9 +144,9 @@ </b-modal> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.handlersData = ${json.dumps(handlers_data)|n} @@ -203,3 +203,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako index a9625bc3..2bc2a4e9 100644 --- a/tailbone/templates/importing/runjob.mako +++ b/tailbone/templates/importing/runjob.mako @@ -63,26 +63,28 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> - ${form.vue_component}Data.submittingRun = false - ${form.vue_component}Data.submittingExplain = false - ${form.vue_component}Data.runJob = false + ${form.component_studly}Data.submittingRun = false + ${form.component_studly}Data.submittingExplain = false + ${form.component_studly}Data.runJob = false - ${form.vue_component}.methods.submitRun = function() { + ${form.component_studly}.methods.submitRun = function() { this.submittingRun = true this.runJob = true this.$nextTick(() => { - this.$refs.${form.vue_component}.submit() + this.$refs.${form.component_studly}.submit() }) } - ${form.vue_component}.methods.submitExplain = function() { + ${form.component_studly}.methods.submitExplain = function() { this.submittingExplain = true - this.$refs.${form.vue_component}.submit() + this.$refs.${form.component_studly}.submit() } </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index d2ea7828..d18323b5 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -1,17 +1,86 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/auth/login.mako" /> +<%inherit file="/form.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()"> ${parent.extra_styles()} - <style> - .card-content .buttons { + <style type="text/css"> + .logo img { + 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; } </style> </%def> -## DEPRECATED; remains for back-compat +<%def name="logo()"> + ${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()"> ${self.page_content()} </%def> + +<%def name="page_content()"> + <div class="logo"> + ${self.logo()} + </div> + + <div class="columns is-centered"> + <div class="column is-narrow"> + <div class="card"> + <div class="card-content"> + <tailbone-form></tailbone-form> + </div> + </div> + </div> + </div> +</%def> + +<%def name="modify_this_page_vars()"> + <script type="text/javascript"> + + ${form.component_studly}Data.usernameInput = null + + ${form.component_studly}.mounted = function() { + this.$refs.username.focus() + this.usernameInput = this.$refs.username.$el.querySelector('input') + this.usernameInput.addEventListener('keydown', this.usernameKeydown) + } + + ${form.component_studly}.beforeDestroy = function() { + this.usernameInput.removeEventListener('keydown', this.usernameKeydown) + } + + ${form.component_studly}.methods.usernameKeydown = function(event) { + if (event.which == 13) { + event.preventDefault() + this.$refs.password.focus() + } + } + + </script> +</%def> + + +${parent.body()} diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index de364828..49060ceb 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -297,9 +297,9 @@ </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n} ThisPageData.overnightTaskShowDialog = false @@ -425,3 +425,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index 0dd72d01..b5134c25 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -255,9 +255,9 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> % if master.has_perm('restart_scheduler'): @@ -374,3 +374,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako index 4c7e4662..59d6aea2 100644 --- a/tailbone/templates/master/clone.mako +++ b/tailbone/templates/master/clone.mako @@ -34,9 +34,9 @@ ${h.end_form()} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> TailboneFormData.formSubmitting = false TailboneFormData.submitButtonText = "Yes, please clone away" @@ -48,3 +48,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako index d7dcbbd8..27cd404c 100644 --- a/tailbone/templates/master/create.mako +++ b/tailbone/templates/master/create.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%def name="title()">New ${model_title_plural if getattr(master, 'creates_multiple', False) else model_title}</%def> +<%def name="title()">New ${model_title_plural if master.creates_multiple else model_title}</%def> ${parent.body()} diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index d2f517d9..30bb50ab 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -27,21 +27,26 @@ <b-button type="is-primary is-danger" native-type="submit" :disabled="formSubmitting"> - {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} + {{ formButtonText }} </b-button> </div> ${h.end_form()} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> - ${form.vue_component}Data.formSubmitting = false + TailboneFormData.formSubmitting = false + TailboneFormData.formButtonText = "Yes, please DELETE this data forever!" - ${form.vue_component}.methods.submitForm = function() { + TailboneForm.methods.submitForm = function() { this.formSubmitting = true + this.formButtonText = "Working, please wait..." } </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index 17063c21..dfe56fa8 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -1,18 +1,18 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ## declare extra data needed by form - % if form is not Undefined and getattr(form, 'json_data', None): + % if form is not Undefined: % for key, value in form.json_data.items(): - ${form.vue_component}Data.${key} = ${json.dumps(value)|n} + ${form.component_studly}Data.${key} = ${json.dumps(value)|n} % endfor % endif - % if master.deletable and instance_deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': + % if master.deletable and instance_deletable and master.has_perm('delete') and master.delete_confirm == 'simple': ThisPage.methods.deleteObject = function() { if (confirm("Are you sure you wish to delete this ${model_title}?")) { @@ -23,8 +23,11 @@ % endif </script> - % if form is not Undefined and hasattr(form, 'render_included_templates'): + % if form is not Undefined: ${form.render_included_templates()} % endif </%def> + + +${parent.body()} diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index a2d26c60..33592559 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -15,7 +15,7 @@ <%def name="grid_tools()"> ## grid totals - % if getattr(master, 'supports_grid_totals', False): + % if master.supports_grid_totals: <div style="display: flex; align-items: center;"> <b-button v-if="gridTotalsDisplay == null" :disabled="gridTotalsFetching" @@ -30,7 +30,7 @@ % endif ## download search results - % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): + % if master.results_downloadable and master.has_perm('download_results'): <div> <b-button type="is-primary" icon-pack="fas" @@ -180,7 +180,7 @@ % endif ## download rows for search results - % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): + % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): <b-button type="is-primary" icon-pack="fas" icon-left="download" @@ -194,7 +194,7 @@ % endif ## merge 2 objects - % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)): + % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} ${h.csrf_token(request)} @@ -212,7 +212,7 @@ % endif ## enable / disable selected objects - % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): + % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} ${h.csrf_token(request)} @@ -234,7 +234,7 @@ % endif ## delete selected objects - % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): + % if master.set_deletable and master.has_perm('delete_set'): ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} ${h.csrf_token(request)} ${h.hidden('uuids', v_model='selected_uuids')} @@ -249,7 +249,7 @@ % endif ## delete search results - % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)): + % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} ${h.csrf_token(request)} <b-button type="is-danger" @@ -265,11 +265,6 @@ </%def> -## DEPRECATED; remains for back-compat -<%def name="render_this_page()"> - ${self.page_content()} -</%def> - <%def name="page_content()"> % if download_results_path: @@ -288,42 +283,56 @@ ${self.render_grid_component()} - % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': + % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': ${h.form('#', ref='deleteObjectForm')} ${h.csrf_token(request)} ${h.end_form()} % endif </%def> -<%def name="render_grid_component()"> - ${grid.render_vue_tag()} -</%def> - -############################## -## vue components -############################## - -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} - - ## DEPRECATED; called for back-compat - ${self.make_grid_component()} -</%def> - -## DEPRECATED; remains for back-compat <%def name="make_grid_component()"> - ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} + ## TODO: stop using |n filter? + ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="render_grid_component()"> + <${grid.component} ref="grid" :csrftoken="csrftoken" + % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': + @deleteActionClicked="deleteObject" + % endif + > + </${grid.component}> +</%def> + +<%def name="make_this_page_component()"> + + ## define grid + ${self.make_grid_component()} + + ${parent.make_this_page_component()} + + ## finalize grid + <script> + + ${grid.component_studly}.data = () => { return ${grid.component_studly}Data } + Vue.component('${grid.component}', ${grid.component_studly}) + + </script> +</%def> + +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} <script type="text/javascript"> - % if getattr(master, 'supports_grid_totals', False): - ${grid.vue_component}Data.gridTotalsDisplay = null - ${grid.vue_component}Data.gridTotalsFetching = false + % if master.supports_grid_totals: + ${grid.component_studly}Data.gridTotalsDisplay = null + ${grid.component_studly}Data.gridTotalsFetching = false - ${grid.vue_component}.methods.gridTotalsFetch = function() { + ${grid.component_studly}.methods.gridTotalsFetch = function() { this.gridTotalsFetching = true let url = '${url(f'{route_prefix}.fetch_grid_totals')}' @@ -335,7 +344,7 @@ }) } - ${grid.vue_component}.methods.appliedFiltersHook = function() { + ${grid.component_studly}.methods.appliedFiltersHook = function() { this.gridTotalsDisplay = null this.gridTotalsFetching = false } @@ -379,7 +388,7 @@ % endif ## delete single object - % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': + % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple': ThisPage.methods.deleteObject = function(url) { if (confirm("Are you sure you wish to delete this ${model_title}?")) { let form = this.$refs.deleteObjectForm @@ -390,19 +399,19 @@ % endif ## download results - % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): + % if master.results_downloadable and master.has_perm('download_results'): - ${grid.vue_component}Data.downloadResultsFormat = '${master.download_results_default_format()}' - ${grid.vue_component}Data.showDownloadResultsDialog = false - ${grid.vue_component}Data.downloadResultsFieldsMode = 'default' - ${grid.vue_component}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n} - ${grid.vue_component}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} - ${grid.vue_component}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} + ${grid.component_studly}Data.downloadResultsFormat = '${master.download_results_default_format()}' + ${grid.component_studly}Data.showDownloadResultsDialog = false + ${grid.component_studly}Data.downloadResultsFieldsMode = 'default' + ${grid.component_studly}Data.downloadResultsFieldsAvailable = ${json.dumps(download_results_fields_available)|n} + ${grid.component_studly}Data.downloadResultsFieldsDefault = ${json.dumps(download_results_fields_default)|n} + ${grid.component_studly}Data.downloadResultsFieldsIncluded = ${json.dumps(download_results_fields_default)|n} - ${grid.vue_component}Data.downloadResultsExcludedFieldsSelected = [] - ${grid.vue_component}Data.downloadResultsIncludedFieldsSelected = [] + ${grid.component_studly}Data.downloadResultsExcludedFieldsSelected = [] + ${grid.component_studly}Data.downloadResultsIncludedFieldsSelected = [] - ${grid.vue_component}.computed.downloadResultsFieldsExcluded = function() { + ${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() { let excluded = [] this.downloadResultsFieldsAvailable.forEach(field => { if (!this.downloadResultsFieldsIncluded.includes(field)) { @@ -412,7 +421,7 @@ return excluded } - ${grid.vue_component}.methods.downloadResultsExcludeFields = function() { + ${grid.component_studly}.methods.downloadResultsExcludeFields = function() { const selected = Array.from(this.downloadResultsIncludedFieldsSelected) if (!selected) { return @@ -436,7 +445,7 @@ }) } - ${grid.vue_component}.methods.downloadResultsIncludeFields = function() { + ${grid.component_studly}.methods.downloadResultsIncludeFields = function() { const selected = Array.from(this.downloadResultsExcludedFieldsSelected) if (!selected) { return @@ -457,28 +466,28 @@ }) } - ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() { + ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() { this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault) this.downloadResultsFieldsMode = 'default' } - ${grid.vue_component}.methods.downloadResultsUseAllFields = function() { + ${grid.component_studly}.methods.downloadResultsUseAllFields = function() { this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable) this.downloadResultsFieldsMode = 'all' } - ${grid.vue_component}.methods.downloadResultsSubmit = function() { + ${grid.component_studly}.methods.downloadResultsSubmit = function() { this.$refs.download_results_form.submit() } % endif ## download rows for results - % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): + % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): - ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false - ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results" + ${grid.component_studly}Data.downloadResultsRowsButtonDisabled = false + ${grid.component_studly}Data.downloadResultsRowsButtonText = "Download Rows for Results" - ${grid.vue_component}.methods.downloadResultsRows = function() { + ${grid.component_studly}.methods.downloadResultsRows = function() { if (confirm("This will generate an Excel file which contains " + "not the results themselves, but the *rows* for " + "each.\n\nAre you sure you want this?")) { @@ -490,12 +499,12 @@ % endif ## enable / disable selected objects - % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): + % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): - ${grid.vue_component}Data.enableSelectedSubmitting = false - ${grid.vue_component}Data.enableSelectedText = "Enable Selected" + ${grid.component_studly}Data.enableSelectedSubmitting = false + ${grid.component_studly}Data.enableSelectedText = "Enable Selected" - ${grid.vue_component}.computed.enableSelectedDisabled = function() { + ${grid.component_studly}.computed.enableSelectedDisabled = function() { if (this.enableSelectedSubmitting) { return true } @@ -505,7 +514,7 @@ return false } - ${grid.vue_component}.methods.enableSelectedSubmit = function() { + ${grid.component_studly}.methods.enableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -520,10 +529,10 @@ this.$refs.enable_selected_form.submit() } - ${grid.vue_component}Data.disableSelectedSubmitting = false - ${grid.vue_component}Data.disableSelectedText = "Disable Selected" + ${grid.component_studly}Data.disableSelectedSubmitting = false + ${grid.component_studly}Data.disableSelectedText = "Disable Selected" - ${grid.vue_component}.computed.disableSelectedDisabled = function() { + ${grid.component_studly}.computed.disableSelectedDisabled = function() { if (this.disableSelectedSubmitting) { return true } @@ -533,7 +542,7 @@ return false } - ${grid.vue_component}.methods.disableSelectedSubmit = function() { + ${grid.component_studly}.methods.disableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -551,12 +560,12 @@ % endif ## delete selected objects - % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): + % if master.set_deletable and master.has_perm('delete_set'): - ${grid.vue_component}Data.deleteSelectedSubmitting = false - ${grid.vue_component}Data.deleteSelectedText = "Delete Selected" + ${grid.component_studly}Data.deleteSelectedSubmitting = false + ${grid.component_studly}Data.deleteSelectedText = "Delete Selected" - ${grid.vue_component}.computed.deleteSelectedDisabled = function() { + ${grid.component_studly}.computed.deleteSelectedDisabled = function() { if (this.deleteSelectedSubmitting) { return true } @@ -566,7 +575,7 @@ return false } - ${grid.vue_component}.methods.deleteSelectedSubmit = function() { + ${grid.component_studly}.methods.deleteSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -582,12 +591,12 @@ } % endif - % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'): + % if master.bulk_deletable and master.has_perm('bulk_delete'): - ${grid.vue_component}Data.deleteResultsSubmitting = false - ${grid.vue_component}Data.deleteResultsText = "Delete Results" + ${grid.component_studly}Data.deleteResultsSubmitting = false + ${grid.component_studly}Data.deleteResultsText = "Delete Results" - ${grid.vue_component}.computed.deleteResultsDisabled = function() { + ${grid.component_studly}.computed.deleteResultsDisabled = function() { if (this.deleteResultsSubmitting) { return true } @@ -597,7 +606,7 @@ return false } - ${grid.vue_component}.methods.deleteResultsSubmit = function() { + ${grid.component_studly}.methods.deleteResultsSubmit = function() { // TODO: show "plural model title" here? if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { return @@ -610,12 +619,12 @@ % endif - % if getattr(master, 'mergeable', False) and master.has_perm('merge'): + % if master.mergeable and master.has_perm('merge'): - ${grid.vue_component}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" - ${grid.vue_component}Data.mergeFormSubmitting = false + ${grid.component_studly}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" + ${grid.component_studly}Data.mergeFormSubmitting = false - ${grid.vue_component}.methods.submitMergeForm = function() { + ${grid.component_studly}.methods.submitMergeForm = function() { this.mergeFormSubmitting = true this.mergeFormButtonText = "Working, please wait..." } @@ -623,10 +632,5 @@ </script> </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - <script> - ${grid.vue_component}.data = function() { return ${grid.vue_component}Data } - Vue.component('${grid.vue_tagname}', ${grid.vue_component}) - </script> -</%def> + +${parent.body()} diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako index 487d258d..5d90043f 100644 --- a/tailbone/templates/master/merge.mako +++ b/tailbone/templates/master/merge.mako @@ -109,8 +109,8 @@ <merge-buttons></merge-buttons> </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} <script type="text/x-template" id="merge-buttons-template"> <div class="level" style="margin-top: 2em;"> @@ -147,7 +147,11 @@ </div> </div> </script> - <script> +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + <script type="text/javascript"> const MergeButtons = { template: '#merge-buttons-template', @@ -171,13 +175,12 @@ } } + Vue.component('merge-buttons', MergeButtons) + + <% request.register_component('merge-buttons', 'MergeButtons') %> + </script> </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - <script> - Vue.component('merge-buttons', MergeButtons) - <% request.register_component('merge-buttons', 'MergeButtons') %> - </script> -</%def> + +${parent.body()} diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako index a6bb14f0..307674b8 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -16,16 +16,27 @@ ${self.page_content()} </%def> +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + <script type="text/javascript"> + + TailboneGrid.data = function() { return TailboneGridData } + + Vue.component('tailbone-grid', TailboneGrid) + + </script> +</%def> + +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + + ## TODO: stop using |n filter + ${grid.render_complete()|n} +</%def> + <%def name="page_content()"> - ${grid.render_vue_tag(**{':csrftoken': 'csrftoken'})} + <tailbone-grid :csrftoken="csrftoken"> + </tailbone-grid> </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} - ${grid.render_vue_template()} -</%def> - -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - ${grid.render_vue_finalize()} -</%def> +${parent.body()} diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 118c028c..fe44caa9 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -8,7 +8,7 @@ </%def> <%def name="render_instance_header_title_extras()"> - % if getattr(master, 'touchable', False) and master.has_perm('touch'): + % if master.touchable and master.has_perm('touch'): <b-button title=""Touch" this record to trigger sync" @click="touchRecord()" :disabled="touchSubmitting"> @@ -93,7 +93,7 @@ ${parent.render_this_page()} ## render row grid - % if getattr(master, 'has_rows', False): + % if master.has_rows: <br /> % if rows_title: <h4 class="block is-size-4">${rows_title}</h4> @@ -120,7 +120,9 @@ </p> </div> - ${versions_grid.render_vue_tag(ref='versionsGrid', **{'@view-revision': 'viewRevision'})} + <versions-grid ref="versionsGrid" + @view-revision="viewRevision"> + </versions-grid> <${b}-modal :width="1200" % if request.use_oruga: @@ -196,7 +198,6 @@ <p class="block has-text-weight-bold"> {{ version.model_title }} - ({{ version.operation }}) </p> <table class="diff monospace is-size-7" @@ -236,37 +237,25 @@ </%def> <%def name="render_row_grid_component()"> - ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')} + <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid> </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} - % if getattr(master, 'has_rows', False): - ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))} +<%def name="render_this_page_template()"> + % if master.has_rows: + ## TODO: stop using |n filter + ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} % endif + ${parent.render_this_page_template()} % if expose_versions: - ${versions_grid.render_vue_template()} + ${versions_grid.render_complete()|n} % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if expose_versions: + <script type="text/javascript"> - % if getattr(master, 'touchable', False) and master.has_perm('touch'): - - WholePageData.touchSubmitting = false - - WholePage.methods.touchRecord = function() { - this.touchSubmitting = true - location.href = '${master.get_action_url('touch', instance)}' - } - - % endif - - % if expose_versions: - - WholePageData.viewingHistory = false ThisPage.props.viewingHistory = Boolean ThisPageData.gettingRevisions = false @@ -321,16 +310,48 @@ this.viewVersionShowAllFields = !this.viewVersionShowAllFields } + </script> + % endif +</%def> + +<%def name="modify_whole_page_vars()"> + ${parent.modify_whole_page_vars()} + <script type="text/javascript"> + + % if master.touchable and master.has_perm('touch'): + + WholePageData.touchSubmitting = false + + WholePage.methods.touchRecord = function() { + this.touchSubmitting = true + location.href = '${master.get_action_url('touch', instance)}' + } + % endif + + % if expose_versions: + WholePageData.viewingHistory = false + % endif + </script> </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - % if getattr(master, 'has_rows', False): - ${rows_grid.render_vue_finalize()} - % endif - % if expose_versions: - ${versions_grid.render_vue_finalize()} - % endif +<%def name="finalize_this_page_vars()"> + ${parent.finalize_this_page_vars()} + <script type="text/javascript"> + + % if master.has_rows: + TailboneGrid.data = function() { return TailboneGridData } + Vue.component('tailbone-grid', TailboneGrid) + % endif + + % if expose_versions: + VersionsGrid.data = function() { return VersionsGridData } + Vue.component('versions-grid', VersionsGrid) + % endif + + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako index f1f0e39f..465bf611 100644 --- a/tailbone/templates/members/configure.mako +++ b/tailbone/templates/members/configure.mako @@ -52,9 +52,9 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPage.methods.getLabelForKey = function(key) { switch (key) { @@ -75,3 +75,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index 39236f75..4a15573b 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -32,14 +32,14 @@ % endif </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} ${message_recipients_template()} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n}) TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n} @@ -59,3 +59,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako index eaa4b6c9..3fc82fd3 100644 --- a/tailbone/templates/messages/index.mako +++ b/tailbone/templates/messages/index.mako @@ -22,15 +22,15 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if request.matched_route.name in ('messages.inbox', 'messages.archive'): - <script> + <script type="text/javascript"> - ${grid.vue_component}Data.moveMessagesSubmitting = false - ${grid.vue_component}Data.moveMessagesText = null + TailboneGridData.moveMessagesSubmitting = false + TailboneGridData.moveMessagesText = null - ${grid.vue_component}.computed.moveMessagesTextCurrent = function() { + TailboneGrid.computed.moveMessagesTextCurrent = function() { if (this.moveMessagesText) { return this.moveMessagesText } @@ -38,7 +38,7 @@ return "Move " + count.toString() + " selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}" } - ${grid.vue_component}.methods.moveMessagesSubmit = function() { + TailboneGrid.methods.moveMessagesSubmit = function() { this.moveMessagesSubmitting = true this.moveMessagesText = "Working, please wait..." } @@ -46,3 +46,6 @@ </script> % endif </%def> + + +${parent.body()} diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako index 36418698..2e2baa60 100644 --- a/tailbone/templates/messages/view.mako +++ b/tailbone/templates/messages/view.mako @@ -82,19 +82,22 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> - ${form.vue_component}Data.showingAllRecipients = false + TailboneFormData.showingAllRecipients = false - ${form.vue_component}.methods.showMoreRecipients = function() { + TailboneForm.methods.showMoreRecipients = function() { this.showingAllRecipients = true } - ${form.vue_component}.methods.hideMoreRecipients = function() { + TailboneForm.methods.hideMoreRecipients = function() { this.showingAllRecipients = false } </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/ordering/configure.mako b/tailbone/templates/ordering/configure.mako deleted file mode 100644 index dc505c42..00000000 --- a/tailbone/templates/ordering/configure.mako +++ /dev/null @@ -1,74 +0,0 @@ -## -*- 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 "supported" 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> diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index 34a6085f..aed6fd75 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -21,8 +21,8 @@ % endif </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): <script type="text/x-template" id="ordering-scanner-template"> <div> @@ -185,10 +185,10 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): - <script> + <script type="text/javascript"> let OrderingScanner = { template: '#ordering-scanner-template', @@ -204,7 +204,7 @@ saving: false, ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, + csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, } }, computed: { @@ -408,11 +408,16 @@ % endif </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): - <script> + <script type="text/javascript"> + Vue.component('ordering-scanner', OrderingScanner) + </script> % endif </%def> + + +${parent.body()} diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index eb2077e7..ca1abf6e 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -199,8 +199,9 @@ <ordering-worksheet></ordering-worksheet> </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + <script type="text/x-template" id="ordering-worksheet-template"> <div> <div class="form-wrapper"> @@ -238,7 +239,11 @@ ${self.order_form_grid()} </div> </script> - <script> +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + <script type="text/javascript"> const OrderingWorksheet = { template: '#ordering-worksheet-template', @@ -250,7 +255,7 @@ submitting: false, ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, + csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, } }, methods: { @@ -293,12 +298,14 @@ }, } + Vue.component('ordering-worksheet', OrderingWorksheet) + </script> </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - <script> - Vue.component('ordering-worksheet', OrderingWorksheet) - </script> -</%def> + +############################## +## page body +############################## + +${parent.body()} diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 43b0a266..17d87c9a 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -1,26 +1,42 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} - ${self.render_vue_template_this_page()} +<%def name="context_menu_items()"> + % if context_menu_list_items is not Undefined: + % for item in context_menu_list_items: + <li>${item}</li> + % endfor + % endif </%def> -<%def name="render_vue_template_this_page()"> - ## DEPRECATED; called for back-compat - ${self.render_this_page_template()} +<%def name="page_content()"></%def> + +<%def name="render_this_page()"> + <div style="display: flex;"> + + <div class="this-page-content" style="flex-grow: 1;"> + ${self.page_content()} + </div> + + <ul id="context-menu"> + ${self.context_menu_items()} + </ul> + + </div> </%def> <%def name="render_this_page_template()"> <script type="text/x-template" id="this-page-template"> <div> - ## DEPRECATED; called for back-compat ${self.render_this_page()} </div> </script> - <script> +</%def> - const ThisPage = { +<%def name="declare_this_page_vars()"> + <script type="text/javascript"> + + let ThisPage = { template: '#this-page-template', mixins: [SimpleRequestMixin], props: { @@ -36,71 +52,37 @@ }, } - const ThisPageData = { + let ThisPageData = { ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, + csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, } </script> </%def> -## DEPRECATED; remains for back-compat -<%def name="render_this_page()"> - <div style="display: flex;"> - - <div class="this-page-content" style="flex-grow: 1;"> - ${self.page_content()} - </div> - - ## DEPRECATED; remains for back-compat - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> - </div> +<%def name="modify_this_page_vars()"> + ## NOTE: if you override this, must use <script> tags </%def> -## nb. this is the canonical block for page content! -<%def name="page_content()"></%def> - -## DEPRECATED; remains for back-compat -<%def name="context_menu_items()"> - % if context_menu_list_items is not Undefined: - % for item in context_menu_list_items: - <li>${item}</li> - % endfor - % endif -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - - ## DEPRECATED; called for back-compat - ${self.declare_this_page_vars()} - ${self.modify_this_page_vars()} -</%def> - -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - - ## DEPRECATED; called for back-compat - ${self.make_this_page_component()} +<%def name="finalize_this_page_vars()"> + ## NOTE: if you override this, must use <script> tags </%def> <%def name="make_this_page_component()"> + ${self.declare_this_page_vars()} + ${self.modify_this_page_vars()} ${self.finalize_this_page_vars()} - <script> + + <script type="text/javascript"> + ThisPage.data = function() { return ThisPageData } + Vue.component('this-page', ThisPage) <% request.register_component('this-page', 'ThisPage') %> + </script> </%def> -############################## -## DEPRECATED -############################## -<%def name="declare_this_page_vars()"></%def> - -<%def name="modify_this_page_vars()"></%def> - -<%def name="finalize_this_page_vars()"></%def> +${self.render_this_page_template()} +${self.make_this_page_component()} diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index cd6fddf1..c819050a 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -3,7 +3,7 @@ <%def name="grid_tools()"> - % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): + % if master.mergeable and master.has_perm('request_merge'): <b-button @click="showMergeRequest()" icon-pack="fas" icon-left="object-ungroup" @@ -61,37 +61,37 @@ ${parent.grid_tools()} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> - % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): + % if master.mergeable and master.has_perm('request_merge'): - ${grid.vue_component}Data.mergeRequestShowDialog = false - ${grid.vue_component}Data.mergeRequestRows = [] - ${grid.vue_component}Data.mergeRequestSubmitText = "Submit Merge Request" - ${grid.vue_component}Data.mergeRequestSubmitting = false + ${grid.component_studly}Data.mergeRequestShowDialog = false + ${grid.component_studly}Data.mergeRequestRows = [] + ${grid.component_studly}Data.mergeRequestSubmitText = "Submit Merge Request" + ${grid.component_studly}Data.mergeRequestSubmitting = false - ${grid.vue_component}.computed.mergeRequestRemovingUUID = function() { + ${grid.component_studly}.computed.mergeRequestRemovingUUID = function() { if (this.mergeRequestRows.length) { return this.mergeRequestRows[0].uuid } return null } - ${grid.vue_component}.computed.mergeRequestKeepingUUID = function() { + ${grid.component_studly}.computed.mergeRequestKeepingUUID = function() { if (this.mergeRequestRows.length) { return this.mergeRequestRows[1].uuid } return null } - ${grid.vue_component}.methods.showMergeRequest = function() { + ${grid.component_studly}.methods.showMergeRequest = function() { this.mergeRequestRows = this.checkedRows this.mergeRequestShowDialog = true } - ${grid.vue_component}.methods.submitMergeRequest = function() { + ${grid.component_studly}.methods.submitMergeRequest = function() { this.mergeRequestSubmitting = true this.mergeRequestSubmitText = "Working, please wait..." } @@ -100,3 +100,5 @@ </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako index e2db1476..9e8905cf 100644 --- a/tailbone/templates/people/merge-requests/view.mako +++ b/tailbone/templates/people/merge-requests/view.mako @@ -18,10 +18,10 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if not instance.merged and request.has_perm('people.merge'): - <script> + <script type="text/javascript"> ThisPageData.mergeFormButtonText = "Perform Merge" ThisPageData.mergeFormSubmitting = false @@ -34,3 +34,5 @@ </script> % endif </%def> + +${parent.body()} diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako index 15c669fa..184f2b91 100644 --- a/tailbone/templates/people/view.mako +++ b/tailbone/templates/people/view.mako @@ -2,16 +2,6 @@ <%inherit file="/master/view.mako" /> <%namespace file="/util.mako" import="view_profiles_helper" /> -<%def name="page_content()"> - ${parent.page_content()} - % if not instance.users and request.has_perm('users.create'): - ${h.form(url('people.make_user'), ref='makeUserForm')} - ${h.csrf_token(request)} - ${h.hidden('person_uuid', value=instance.uuid)} - ${h.end_form()} - % endif -</%def> - <%def name="object_helpers()"> ${parent.object_helpers()} ${view_profiles_helper([instance])} @@ -19,15 +9,15 @@ <%def name="render_form()"> <div class="form"> - <${form.vue_tagname} v-on:make-user="makeUser"></${form.vue_tagname}> + <tailbone-form v-on:make-user="makeUser"></tailbone-form> </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> - ${form.vue_component}.methods.clickMakeUser = function(event) { + TailboneForm.methods.clickMakeUser = function(event) { this.$emit('make-user') } @@ -39,3 +29,17 @@ </script> </%def> + +<%def name="page_content()"> + ${parent.page_content()} + % if not instance.users and request.has_perm('users.create'): + ${h.form(url('people.make_user'), ref='makeUserForm')} + ${h.csrf_token(request)} + ${h.hidden('person_uuid', value=instance.uuid)} + ${h.end_form()} + % endif +</%def> + + +${parent.body()} + diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 6ca5a84c..8044f7c6 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -15,7 +15,7 @@ </%def> <%def name="content_title()"> - ${dynamic_content_title or str(instance)} + ${dynamic_content_title} </%def> <%def name="render_instance_header_title_extras()"> @@ -1008,7 +1008,7 @@ <div style="display: flex; justify-content: space-between; width: 100%;"> <div style="flex-grow: 1;"> - <b-field horizontal label="${customer_key_label or 'TODO: Customer Key'}"> + <b-field horizontal label="${customer_key_label}"> {{ customer._key }} </b-field> @@ -1966,106 +1966,37 @@ </div> </script> - <script> +</%def> - let ProfileInfoData = { - activeTab: location.hash ? location.hash.substring(1) : 'personal', - tabchecks: ${json.dumps(tabchecks or {})|n}, - today: '${rattail_app.today()}', - profileLastChanged: Date.now(), - person: ${json.dumps(person_data or {})|n}, - phoneTypeOptions: ${json.dumps(phone_type_options or [])|n}, - emailTypeOptions: ${json.dumps(email_type_options or [])|n}, - maxLengths: ${json.dumps(max_lengths or {})|n}, +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + ${self.render_personal_tab_template()} - % if request.has_perm('people_profile.view_versions'): - loadingRevisions: false, - showingRevisionDialog: false, - revision: {}, - revisionShowAllFields: false, - % endif - } + % if expose_members: + ${self.render_member_tab_template()} + % endif - let ProfileInfo = { - template: '#profile-info-template', - props: { - % if request.has_perm('people_profile.view_versions'): - viewingHistory: Boolean, - gettingRevisions: Boolean, - revisions: Array, - revisionVersionMap: null, - % endif - }, - computed: {}, - mounted() { + ${self.render_customer_tab_template()} + % if expose_customer_shoppers: + ${self.render_shopper_tab_template()} + % endif + ${self.render_employee_tab_template()} + ${self.render_notes_tab_template()} - // auto-refresh whichever tab is shown first - ## TODO: how to not assume 'personal' is the default tab? - let tab = this.$refs['tab_' + (this.activeTab || 'personal')] - if (tab && tab.refreshTab) { - tab.refreshTab() - } - }, - methods: { + % if expose_transactions: + ${transactions_grid.render_complete(allow_save_defaults=False)|n} + ${self.render_transactions_tab_template()} + % endif - profileChanged(data) { - this.$emit('change-content-title', data.person.dynamic_content_title) - this.person = data.person - this.tabchecks = data.tabchecks - this.profileLastChanged = Date.now() - }, - - activeTabChanged(value) { - location.hash = value - this.refreshTabIfNeeded(value) - this.activeTabChangedExtra(value) - }, - - refreshTabIfNeeded(key) { - // TODO: this is *always* refreshing, should be more selective (?) - let tab = this.$refs['tab_' + key] - if (tab && tab.refreshIfNeeded) { - tab.refreshIfNeeded(this.profileLastChanged) - } - }, - - activeTabChangedExtra(value) {}, - - % if request.has_perm('people_profile.view_versions'): - - viewRevision(row) { - this.revision = this.revisionVersionMap[row.txnid] - this.showingRevisionDialog = true - }, - - viewPrevRevision() { - let txnid = this.revision.prev_txnid - this.revision = this.revisionVersionMap[txnid] - }, - - viewNextRevision() { - let txnid = this.revision.next_txnid - this.revision = this.revisionVersionMap[txnid] - }, - - toggleVersionFields() { - this.revisionShowAllFields = !this.revisionShowAllFields - }, - - % endif - }, - } - - </script> + ${self.render_user_tab_template()} + ${self.render_profile_info_template()} </%def> <%def name="declare_personal_tab_vars()"> <script type="text/javascript"> let PersonalTabData = { - % if hasattr(master, 'profile_tab_personal'): refreshTabURL: '${url('people.profile_tab_personal', uuid=person.uuid)}', - % endif // nb. hack to force refresh for vue3 refreshPersonalCard: 1, @@ -2516,9 +2447,7 @@ <script type="text/javascript"> let CustomerTabData = { - % if hasattr(master, 'profile_tab_customer'): refreshTabURL: '${url('people.profile_tab_customer', uuid=person.uuid)}', - % endif customers: [], } @@ -2592,9 +2521,7 @@ <script type="text/javascript"> let EmployeeTabData = { - % if hasattr(master, 'profile_tab_employee'): refreshTabURL: '${url('people.profile_tab_employee', uuid=person.uuid)}', - % endif employee: {}, employeeHistory: [], @@ -2829,9 +2756,7 @@ <script type="text/javascript"> let NotesTabData = { - % if hasattr(master, 'profile_tab_notes'): refreshTabURL: '${url('people.profile_tab_notes', uuid=person.uuid)}', - % endif notes: [], noteTypeOptions: [], @@ -2995,9 +2920,7 @@ <script type="text/javascript"> let UserTabData = { - % if hasattr(master, 'profile_tab_user'): refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}', - % endif users: [], % if request.has_perm('users.create'): @@ -3053,9 +2976,7 @@ createUserSave() { this.createUserSaving = true - % if hasattr(master, 'profile_make_user'): let url = '${master.get_action_url('profile_make_user', instance)}' - % endif let params = { username: this.createUserUsername, active: this.createUserActive, @@ -3089,46 +3010,114 @@ </script> </%def> -<%def name="make_profile_info_component()"> +<%def name="declare_profile_info_vars()"> + <script type="text/javascript"> - ## DEPRECATED; called for back-compat - ${self.declare_profile_info_vars()} + let ProfileInfoData = { + activeTab: location.hash ? location.hash.substring(1) : 'personal', + tabchecks: ${json.dumps(tabchecks)|n}, + today: '${rattail_app.today()}', + profileLastChanged: Date.now(), + person: ${json.dumps(person_data)|n}, + phoneTypeOptions: ${json.dumps(phone_type_options)|n}, + emailTypeOptions: ${json.dumps(email_type_options)|n}, + maxLengths: ${json.dumps(max_lengths)|n}, + + % if request.has_perm('people_profile.view_versions'): + loadingRevisions: false, + showingRevisionDialog: false, + revision: {}, + revisionShowAllFields: false, + % endif + } + + let ProfileInfo = { + template: '#profile-info-template', + props: { + % if request.has_perm('people_profile.view_versions'): + viewingHistory: Boolean, + gettingRevisions: Boolean, + revisions: Array, + revisionVersionMap: null, + % endif + }, + computed: {}, + mounted() { + + // auto-refresh whichever tab is shown first + ## TODO: how to not assume 'personal' is the default tab? + let tab = this.$refs['tab_' + (this.activeTab || 'personal')] + if (tab && tab.refreshTab) { + tab.refreshTab() + } + }, + methods: { + + profileChanged(data) { + this.$emit('change-content-title', data.person.dynamic_content_title) + this.person = data.person + this.tabchecks = data.tabchecks + this.profileLastChanged = Date.now() + }, + + activeTabChanged(value) { + location.hash = value + this.refreshTabIfNeeded(value) + this.activeTabChangedExtra(value) + }, + + refreshTabIfNeeded(key) { + // TODO: this is *always* refreshing, should be more selective (?) + let tab = this.$refs['tab_' + key] + if (tab && tab.refreshIfNeeded) { + tab.refreshIfNeeded(this.profileLastChanged) + } + }, + + activeTabChangedExtra(value) {}, + + % if request.has_perm('people_profile.view_versions'): + + viewRevision(row) { + this.revision = this.revisionVersionMap[row.txnid] + this.showingRevisionDialog = true + }, + + viewPrevRevision() { + let txnid = this.revision.prev_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + viewNextRevision() { + let txnid = this.revision.next_txnid + this.revision = this.revisionVersionMap[txnid] + }, + + toggleVersionFields() { + this.revisionShowAllFields = !this.revisionShowAllFields + }, + + % endif + }, + } - <script> - ProfileInfo.data = function() { return ProfileInfoData } - Vue.component('profile-info', ProfileInfo) - <% request.register_component('profile-info', 'ProfileInfo') %> </script> </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="make_profile_info_component()"> + ${self.declare_profile_info_vars()} + <script type="text/javascript"> - ${self.render_personal_tab_template()} + ProfileInfo.data = function() { return ProfileInfoData } + Vue.component('profile-info', ProfileInfo) + <% request.register_component('profile-info', 'ProfileInfo') %> - % if expose_members: - ${self.render_member_tab_template()} - % endif - - ${self.render_customer_tab_template()} - % if expose_customer_shoppers: - ${self.render_shopper_tab_template()} - % endif - ${self.render_employee_tab_template()} - ${self.render_notes_tab_template()} - - % if expose_transactions: - ${transactions_grid.render_complete(allow_save_defaults=False)|n} - ${self.render_transactions_tab_template()} - % endif - - ${self.render_user_tab_template()} - ${self.render_profile_info_template()} + </script> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> % if request.has_perm('people_profile.view_versions'): ThisPage.props.viewingHistory = Boolean @@ -3176,8 +3165,45 @@ }, } + </script> +</%def> - % if request.has_perm('people_profile.view_versions'): +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} + ${self.make_personal_tab_component()} + + % if expose_members: + ${self.make_member_tab_component()} + % endif + + ${self.make_customer_tab_component()} + % if expose_customer_shoppers: + ${self.make_shopper_tab_component()} + % endif + ${self.make_employee_tab_component()} + ${self.make_notes_tab_component()} + + % if expose_transactions: + <script type="text/javascript"> + + TransactionsGrid.data = function() { return TransactionsGridData } + Vue.component('transactions-grid', TransactionsGrid) + ## TODO: why is this line not needed? + ## <% request.register_component('transactions-grid', 'TransactionsGrid') %> + + </script> + ${self.make_transactions_tab_component()} + % endif + + ${self.make_user_tab_component()} + ${self.make_profile_info_component()} +</%def> + +<%def name="modify_whole_page_vars()"> + ${parent.modify_whole_page_vars()} + + % if request.has_perm('people_profile.view_versions'): + <script type="text/javascript"> WholePageData.viewingHistory = false WholePageData.gettingRevisions = false @@ -3213,44 +3239,9 @@ }) } - % endif - </script> -</%def> - -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - - ${self.make_personal_tab_component()} - - % if expose_members: - ${self.make_member_tab_component()} - % endif - - ${self.make_customer_tab_component()} - % if expose_customer_shoppers: - ${self.make_shopper_tab_component()} - % endif - ${self.make_employee_tab_component()} - ${self.make_notes_tab_component()} - - % if expose_transactions: - <script type="text/javascript"> - - TransactionsGrid.data = function() { return TransactionsGridData } - Vue.component('transactions-grid', TransactionsGrid) - ## TODO: why is this line not needed? - ## <% request.register_component('transactions-grid', 'TransactionsGrid') %> - </script> - ${self.make_transactions_tab_component()} % endif - - ${self.make_user_tab_component()} - ${self.make_profile_info_component()} </%def> -############################## -## DEPRECATED -############################## -<%def name="declare_profile_info_vars()"></%def> +${parent.body()} diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako index cb8b51aa..aac0c7ae 100644 --- a/tailbone/templates/poser/reports/view.mako +++ b/tailbone/templates/poser/reports/view.mako @@ -62,13 +62,19 @@ <br /> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if master.has_perm('replace'): - <script> - ${form.vue_component}Data.showUploadForm = false - ${form.vue_component}Data.uploadFile = null - ${form.vue_component}Data.uploadSubmitting = false - </script> + <script type="text/javascript"> + + ${form.component_studly}Data.showUploadForm = false + + ${form.component_studly}Data.uploadFile = null + + ${form.component_studly}Data.uploadSubmitting = false + + </script> % endif </%def> + +${parent.body()} diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako index 239e7db2..8d01bb33 100644 --- a/tailbone/templates/poser/setup.mako +++ b/tailbone/templates/poser/setup.mako @@ -118,9 +118,14 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + ThisPageData.setupSubmitting = false + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index ddc44e3d..1a0a4b7d 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -10,20 +10,12 @@ </find-principals> </%def> -<%def name="principal_table()"> - <div - style="width: 50%;" - > - ${grid.render_table_element(data_prop='principalsData')|n} - </div> -</%def> - -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} <script type="text/x-template" id="find-principals-template"> <div> - ${h.form(request.url, method='GET', **{'@submit': 'formSubmitting = true'})} + ${h.form(request.current_route_url(), method='GET', **{'@submit': 'formSubmitting = true'})} <div style="margin-left: 10rem; max-width: 50%;"> ${h.hidden('permission_group', **{':value': 'selectedGroup'})} @@ -71,7 +63,7 @@ <b-field horizontal> <div class="buttons" style="margin-top: 1rem;"> <once-button tag="a" - href="${request.path_url}" + href="${request.current_route_url(_query=None)}" text="Reset Form"> </once-button> <b-button type="is-primary" @@ -98,6 +90,28 @@ </div> </script> +</%def> + +<%def name="principal_table()"> + <div + style="width: 50%;" + > + ${grid.render_table_element(data_prop='principalsData')|n} + </div> +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ThisPageData.permissionGroups = ${json.dumps(perms_data)|n} + ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n} + + </script> +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} <script type="text/javascript"> const FindPrincipals = { @@ -226,21 +240,12 @@ } } - </script> -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - ThisPageData.permissionGroups = ${json.dumps(perms_data)|n} - ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n} - </script> -</%def> - -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - <script> Vue.component('find-principals', FindPrincipals) + <% request.register_component('find-principals', 'FindPrincipals') %> + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index db029e5a..a4a4d503 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -22,7 +22,7 @@ </%def> <%def name="render_form_innards()"> - ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.vue_component)})} + ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.component_studly)})} ${h.csrf_token(request)} <section> @@ -43,8 +43,8 @@ <div class="buttons"> <b-button type="is-primary" native-type="submit" - :disabled="${form.vue_component}Submitting"> - {{ ${form.vue_component}ButtonText }} + :disabled="${form.component_studly}Submitting"> + {{ ${form.component_studly}ButtonText }} </b-button> <b-button tag="a" href="${url('products')}"> Cancel @@ -55,33 +55,32 @@ </%def> <%def name="render_form_template()"> - <script type="text/x-template" id="${form.vue_tagname}-template"> + <script type="text/x-template" id="${form.component}-template"> ${self.render_form_innards()} </script> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <% request.register_component(form.vue_tagname, form.vue_component) %> - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) - let ${form.vue_component} = { - template: '#${form.vue_tagname}-template', + let ${form.component_studly} = { + template: '#${form.component}-template', methods: { ## TODO: deprecate / remove the latter option here % if form.auto_disable_save or form.auto_disable: - submit${form.vue_component}() { - this.${form.vue_component}Submitting = true - this.${form.vue_component}ButtonText = "Working, please wait..." + submit${form.component_studly}() { + this.${form.component_studly}Submitting = true + this.${form.component_studly}ButtonText = "Working, please wait..." } % endif } } - let ${form.vue_component}Data = { + let ${form.component_studly}Data = { ## TODO: ugh, this seems pretty hacky. need to declare some data models ## for various field components to bind to... @@ -96,8 +95,8 @@ ## TODO: deprecate / remove the latter option here % if form.auto_disable_save or form.auto_disable: - ${form.vue_component}Submitting: false, - ${form.vue_component}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, + ${form.component_studly}Submitting: false, + ${form.component_studly}ButtonText: ${json.dumps(getattr(form, 'submit_label', getattr(form, 'save_label', "Submit")))|n}, % endif ## TODO: more hackiness, this is for the sake of batch params @@ -115,3 +114,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index a43a85d4..6121af67 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -95,9 +95,9 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPage.methods.getTitleForKey = function(key) { switch (key) { @@ -118,3 +118,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index 5ffa9512..0d4bc410 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -36,16 +36,16 @@ </${grid.component}> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if label_profiles and master.has_perm('print_labels'): - <script> + <script type="text/javascript"> - ${grid.vue_component}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} - ${grid.vue_component}Data.quickLabelQuantity = 1 - ${grid.vue_component}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n} + ${grid.component_studly}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} + ${grid.component_studly}Data.quickLabelQuantity = 1 + ${grid.component_studly}Data.quickLabelSpeedbumpThreshold = ${json.dumps(quick_label_speedbump_threshold)|n} - ${grid.vue_component}.methods.quickLabelPrint = function(row) { + ${grid.component_studly}.methods.quickLabelPrint = function(row) { let quantity = parseInt(this.quickLabelQuantity) if (isNaN(quantity)) { @@ -83,3 +83,6 @@ </script> % endif </%def> + + +${parent.body()} diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako index 72c9c76d..765c8838 100644 --- a/tailbone/templates/products/pending/view.mako +++ b/tailbone/templates/products/pending/view.mako @@ -2,6 +2,11 @@ <%inherit file="/master/view.mako" /> <%namespace name="product_lookup" file="/products/lookup.mako" /> +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + ${product_lookup.tailbone_product_lookup_template()} +</%def> + <%def name="page_content()"> ${parent.page_content()} @@ -62,14 +67,9 @@ % endif </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} - ${product_lookup.tailbone_product_lookup_template()} -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY): @@ -124,7 +124,10 @@ </script> </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} ${product_lookup.tailbone_product_lookup_component()} </%def> + + +${parent.body()} diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index 66ca3128..bd4afc7f 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -282,9 +282,9 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n} ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n} @@ -411,3 +411,6 @@ % endif </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako index 94028bdb..4248d4ad 100644 --- a/tailbone/templates/purchases/credits/index.mako +++ b/tailbone/templates/purchases/credits/index.mako @@ -59,24 +59,27 @@ </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> - ${grid.vue_component}Data.changeStatusShowDialog = false - ${grid.vue_component}Data.changeStatusOptions = ${json.dumps(status_options)|n} - ${grid.vue_component}Data.changeStatusValue = null - ${grid.vue_component}Data.changeStatusSubmitting = false + ${grid.component_studly}Data.changeStatusShowDialog = false + ${grid.component_studly}Data.changeStatusOptions = ${json.dumps(status_options)|n} + ${grid.component_studly}Data.changeStatusValue = null + ${grid.component_studly}Data.changeStatusSubmitting = false - ${grid.vue_component}.methods.changeStatusInit = function() { + ${grid.component_studly}.methods.changeStatusInit = function() { this.changeStatusValue = null this.changeStatusShowDialog = true } - ${grid.vue_component}.methods.changeStatusSubmit = function() { + ${grid.component_studly}.methods.changeStatusSubmit = function() { this.changeStatusSubmitting = true this.$refs.changeStatusForm.submit() } </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index a36dde43..f613e13e 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -69,12 +69,12 @@ <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 "supported" vendor."> - <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor" - v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']" + <b-field message="If set, user must choose a "supported" vendor; otherwise they may choose "any" vendor."> + <b-checkbox name="rattail.batch.purchase.supported_vendors_only" + v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']" native-value="true" @input="settingsNeedSaved = true"> - Allow receiving for <span class="has-text-weight-bold">any</span> vendor + Only allow batch for "supported" vendors </b-checkbox> </b-field> diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 710dec4a..5f103d7f 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -139,15 +139,9 @@ % endif </%def> -<%def name="object_helpers()"> - ${self.render_status_breakdown()} - ${self.render_po_vs_invoice_helper()} - ${self.render_execute_helper()} - ${self.render_tools_helper()} -</%def> +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost: <script type="text/x-template" id="receiving-cost-editor-template"> <div> @@ -168,9 +162,16 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="object_helpers()"> + ${self.render_status_breakdown()} + ${self.render_po_vs_invoice_helper()} + ${self.render_execute_helper()} + ${self.render_tools_helper()} +</%def> + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> % if allow_confirm_all_costs: @@ -317,13 +318,13 @@ % if allow_edit_catalog_unit_cost: - ${rows_grid.vue_component}.methods.catalogUnitCostClicked = function(row) { + ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) { // start edit for clicked cell this.$refs['catalogUnitCost_' + row.uuid].startEdit() } - ${rows_grid.vue_component}.methods.catalogCostConfirmed = function(amount, index) { + ${rows_grid.component_studly}.methods.catalogCostConfirmed = function(amount, index) { // update display to indicate cost was confirmed this.addRowClass(index, 'catalog_cost_confirmed') @@ -352,13 +353,13 @@ % if allow_edit_invoice_unit_cost: - ${rows_grid.vue_component}.methods.invoiceUnitCostClicked = function(row) { + ${rows_grid.component_studly}.methods.invoiceUnitCostClicked = function(row) { // start edit for clicked cell this.$refs['invoiceUnitCost_' + row.uuid].startEdit() } - ${rows_grid.vue_component}.methods.invoiceCostConfirmed = function(amount, index) { + ${rows_grid.component_studly}.methods.invoiceCostConfirmed = function(amount, index) { // update display to indicate cost was confirmed this.addRowClass(index, 'invoice_cost_confirmed') @@ -388,3 +389,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 086754c6..5077539c 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -484,9 +484,9 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ## ThisPage.methods.editUnitCost = function() { ## alert("TODO: not yet implemented") @@ -720,3 +720,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako index 0921530c..a952fb6a 100644 --- a/tailbone/templates/reports/generated/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -53,13 +53,13 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> - ${form.vue_component}Data.reportDescriptions = ${json.dumps(report_descriptions)|n} + TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n} - ${form.vue_component}.methods.reportTypeChanged = function(reportType) { + TailboneForm.methods.reportTypeChanged = function(reportType) { this.$emit('report-change', this.reportDescriptions[reportType]) } @@ -71,3 +71,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako index f60a9819..0c994ad0 100644 --- a/tailbone/templates/reports/generated/delete.mako +++ b/tailbone/templates/reports/generated/delete.mako @@ -1,11 +1,16 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/delete.mako" /> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + % if params_data is not Undefined: - ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} + ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} % endif + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index cce6f346..6260efba 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -23,11 +23,16 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + % if params_data is not Undefined: - ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} + ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} % endif + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako index cc5adc10..f051959f 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -48,10 +48,15 @@ ${h.end_form()} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + ThisPageData.departments = ${json.dumps([{'uuid': d.uuid, 'name': d.name} for d in departments])|n} ThisPageData.excludeNotForSale = true + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako index 61ccdb16..1e526792 100644 --- a/tailbone/templates/reports/ordering.mako +++ b/tailbone/templates/reports/ordering.mako @@ -81,9 +81,9 @@ <%def name="extra_fields()"></%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.vendorUUID = null ThisPageData.departments = [] @@ -127,3 +127,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index 5cdf2be5..026c73dc 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -45,10 +45,11 @@ <b-button @click="runReportShowDialog = false"> Cancel </b-button> - ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})} + ${h.form(master.get_action_url('execute', instance))} ${h.csrf_token(request)} <b-button type="is-primary" native-type="submit" + @click="runReportSubmitting = true" :disabled="runReportSubmitting" icon-pack="fas" icon-left="arrow-circle-right"> @@ -61,12 +62,12 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> % if weekdays_data is not Undefined: - ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n} + ${form.component_studly}Data.weekdaysData = ${json.dumps(weekdays_data)|n} % endif ThisPageData.runReportShowDialog = false @@ -74,3 +75,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/roles/create.mako b/tailbone/templates/roles/create.mako index 89dd56c3..625b2675 100644 --- a/tailbone/templates/roles/create.mako +++ b/tailbone/templates/roles/create.mako @@ -6,11 +6,15 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + // TODO: this variable name should be more dynamic (?) since this is // connected to (and only here b/c of) the permissions field - ${form.vue_component}Data.showingPermissionGroup = '' + TailboneFormData.showingPermissionGroup = '' + </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/roles/edit.mako b/tailbone/templates/roles/edit.mako index e77cca33..67f63013 100644 --- a/tailbone/templates/roles/edit.mako +++ b/tailbone/templates/roles/edit.mako @@ -6,11 +6,15 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + // TODO: this variable name should be more dynamic (?) since this is // connected to (and only here b/c of) the permissions field - ${form.vue_component}Data.showingPermissionGroup = '' + TailboneFormData.showingPermissionGroup = '' + </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index f5588695..0f4ce472 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -6,12 +6,12 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> % if users_data is not Undefined: - ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n} + ${form.component_studly}Data.usersData = ${json.dumps(users_data)|n} % endif ThisPage.methods.detachPerson = function(url) { @@ -23,3 +23,5 @@ </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index f9c815c2..ef487809 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -86,9 +86,9 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.testRecipient = ${json.dumps(user_email_address)|n} ThisPageData.sendingTest = false @@ -137,3 +137,6 @@ % endif </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako index ab8d6fa4..dbc963b9 100644 --- a/tailbone/templates/settings/email/index.mako +++ b/tailbone/templates/settings/email/index.mako @@ -15,10 +15,10 @@ ${parent.render_grid_component()} </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if master.has_perm('configure'): - <script> + <script type="text/javascript"> ThisPageData.showEmails = 'available' @@ -26,9 +26,9 @@ this.$refs.grid.showEmails = this.showEmails } - ${grid.vue_component}Data.showEmails = 'available' + ${grid.component_studly}Data.showEmails = 'available' - ${grid.vue_component}.computed.visibleData = function() { + ${grid.component_studly}.computed.visibleData = function() { if (this.showEmails == 'available') { return this.data.filter(email => email.hidden == 'No') @@ -41,11 +41,11 @@ return this.data } - ${grid.vue_component}.methods.renderLabelToggleHidden = function(row) { + ${grid.component_studly}.methods.renderLabelToggleHidden = function(row) { return row.hidden == 'Yes' ? "Un-hide" : "Hide" } - ${grid.vue_component}.methods.toggleHidden = function(row) { + ${grid.component_studly}.methods.toggleHidden = function(row) { let url = '${url('{}.toggle_hidden'.format(route_prefix))}' let params = { key: row.key, @@ -65,3 +65,5 @@ </script> % endif </%def> + +${parent.body()} diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index 73ad7066..c1bc5ed4 100644 --- a/tailbone/templates/settings/email/view.mako +++ b/tailbone/templates/settings/email/view.mako @@ -6,8 +6,8 @@ <email-preview-tools></email-preview-tools> </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} <script type="text/x-template" id="email-preview-tools-template"> ${h.form(url('email.preview'), **{'@submit': 'submitPreviewForm'})} @@ -72,6 +72,10 @@ ${h.end_form()} </script> +</%def> + +<%def name="make_this_page_component()"> + ${parent.make_this_page_component()} <script type="text/javascript"> const EmailPreviewTools = { @@ -96,13 +100,12 @@ } } + Vue.component('email-preview-tools', EmailPreviewTools) + + <% request.register_component('email-preview-tools', 'EmailPreviewTools') %> + </script> </%def> -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - <script> - Vue.component('email-preview-tools', EmailPreviewTools) - <% request.register_component('email-preview-tools', 'EmailPreviewTools') %> - </script> -</%def> + +${parent.body()} diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 34844c5c..4fc2eb96 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -695,9 +695,9 @@ </b-steps> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> // nb. for warning user they may lose changes if leaving page ThisPageData.dirty = false @@ -983,3 +983,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako index a55af922..07a524b8 100644 --- a/tailbone/templates/tempmon/appliances/view.mako +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -8,9 +8,14 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n} + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index 434da4c8..cff22fed 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -22,9 +22,14 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + + ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n} + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako index befaf8b4..396b0e68 100644 --- a/tailbone/templates/tempmon/dashboard.mako +++ b/tailbone/templates/tempmon/dashboard.mako @@ -59,9 +59,9 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.appliances = ${json.dumps(appliances_data)|n} ThisPageData.applianceUUID = ${json.dumps(appliance.uuid if appliance else None)|n} @@ -118,3 +118,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako index 94a440e0..412f25dd 100644 --- a/tailbone/templates/tempmon/probes/graph.mako +++ b/tailbone/templates/tempmon/probes/graph.mako @@ -66,9 +66,9 @@ <canvas ref="tempchart" width="400" height="150"></canvas> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.currentTimeRange = ${json.dumps(current_time_range)|n} ThisPageData.chart = null @@ -128,3 +128,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index b69eacfb..b0e43a37 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -20,21 +20,38 @@ </head> <body> - <div id="app" style="height: 100%;"> + <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> <whole-page></whole-page> </div> ## TODO: this must come before the self.body() call..but why? ${declare_formposter_mixin()} + ## global components used by various (but not all) pages + ${make_field_components()} + ${make_grid_filter_components()} + + ## global components for buefy-based template compatibility + ${make_http_plugin()} + ${make_buefy_plugin()} + ${make_buefy_components()} + + ## special global components, used by WholePage + ${self.make_menu_search_component()} + ${page_help.render_template()} + ${page_help.declare_vars()} + % if request.has_perm('common.feedback'): + ${self.make_feedback_component()} + % endif + + ## WholePage component + ${self.make_whole_page_component()} + ## content body from derived/child template ${self.body()} ## Vue app - ${self.render_vue_templates()} - ${self.modify_vue_vars()} - ${self.make_vue_components()} - ${self.make_vue_app()} + ${self.make_whole_page_app()} </body> </html> @@ -54,12 +71,12 @@ { ## TODO: eventually version / url should be configurable "imports": { - "vue": "${h.get_liburl(request, 'bb_vue', prefix='tailbone')}", - "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga', prefix='tailbone')}", - "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma', prefix='tailbone')}", - "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core', prefix='tailbone')}", - "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons', prefix='tailbone')}", - "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome', prefix='tailbone')}" + "vue": "${h.get_liburl(request, 'bb_vue')}", + "@oruga-ui/oruga-next": "${h.get_liburl(request, 'bb_oruga')}", + "@oruga-ui/theme-bulma": "${h.get_liburl(request, 'bb_oruga_bulma')}", + "@fortawesome/fontawesome-svg-core": "${h.get_liburl(request, 'bb_fontawesome_svg_core')}", + "@fortawesome/free-solid-svg-icons": "${h.get_liburl(request, 'bb_free_solid_svg_icons')}", + "@fortawesome/vue-fontawesome": "${h.get_liburl(request, 'bb_vue_fontawesome')}" } } </script> @@ -75,7 +92,7 @@ % if user_css: ${h.stylesheet_link(user_css)} % else: - ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css', prefix='tailbone'))} + ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))} % endif </%def> @@ -579,7 +596,7 @@ </script> </%def> -<%def name="render_vue_template_whole_page()"> +<%def name="render_whole_page_template()"> <script type="text/x-template" id="whole-page-template"> <div id="whole-page" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> @@ -669,7 +686,7 @@ <h1 class="title"> ${index_title} </h1> - % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + % if master.creatable and master.show_create_link and master.has_perm('create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" icon-left="plus" @@ -695,7 +712,7 @@ <h1 class="title"> ${h.link_to(instance_title, instance_url)} </h1> - % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): + % elif master.creatable and master.show_create_link and master.has_perm('create'): % if not request.matched_route.name.endswith('.create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" @@ -879,6 +896,8 @@ </footer> </div> </script> + +## ${multi_file_upload.render_template()} </%def> <%def name="render_this_page_component()"> @@ -909,7 +928,7 @@ ${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()" + <a @click="stopBeingRoot()" class="navbar-item has-background-danger has-text-white"> Stop being root </a> @@ -918,7 +937,7 @@ ${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()" + <a @click="startBeingRoot()" class="navbar-item has-background-danger has-text-white"> Become root </a> @@ -947,23 +966,23 @@ </%def> <%def name="render_crud_header_buttons()"> -% if master and master.viewing and not getattr(master, 'cloning', False): + % if master and master.viewing and not master.cloning: ## TODO: is there a better way to check if viewing parent? % if parent_instance is Undefined: % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${master.get_action_url('edit', instance)}" + <once-button tag="a" href="${action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> % endif - % if not getattr(master, 'cloning', False) and getattr(master, 'cloneable', False) and master.has_perm('clone'): - <once-button tag="a" href="${master.get_action_url('clone', instance)}" + % if not master.cloning and master.cloneable and master.has_perm('clone'): + <once-button tag="a" href="${action_url('clone', instance)}" icon-left="object-ungroup" text="Clone This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${master.get_action_url('delete', instance)}" + <once-button tag="a" href="${action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -972,7 +991,7 @@ % else: ## viewing row % if instance_deletable and master.has_perm('delete_row'): - <once-button tag="a" href="${master.get_action_url('delete', instance)}" + <once-button tag="a" href="${action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -981,13 +1000,13 @@ % endif % elif master and master.editing: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${master.get_action_url('view', instance)}" + <once-button tag="a" href="${action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${master.get_action_url('delete', instance)}" + <once-button tag="a" href="${action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -995,20 +1014,20 @@ % endif % elif master and master.deleting: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${master.get_action_url('view', instance)}" + <once-button tag="a" href="${action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${master.get_action_url('edit', instance)}" + <once-button tag="a" href="${action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> % endif - % elif master and getattr(master, 'cloning', False): + % elif master and master.cloning: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${master.get_action_url('view', instance)}" + <once-button tag="a" href="${action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> @@ -1049,7 +1068,9 @@ % endif </%def> -<%def name="render_vue_script_whole_page()"> +<%def name="declare_whole_page_vars()"> +## ${multi_file_upload.declare_vars()} + <script> const WholePage = { @@ -1103,6 +1124,18 @@ const key = 'menu_' + hash + '_shown' this[key] = !this[key] }, + + % if request.is_admin: + + startBeingRoot() { + this.$refs.startBeingRootForm.submit() + }, + + stopBeingRoot() { + this.$refs.stopBeingRootForm.submit() + }, + + % endif }, } @@ -1139,71 +1172,26 @@ </script> </%def> -############################## -## vue components + app -############################## +<%def name="modify_whole_page_vars()"></%def> -<%def name="render_vue_templates()"> -## ${multi_file_upload.render_template()} -## ${multi_file_upload.declare_vars()} +## TODO: do we really need this? +## <%def name="finalize_whole_page_vars()"></%def> - ## global components used by various (but not all) pages - ${make_field_components()} - ${make_grid_filter_components()} - - ## global components for buefy-based template compatibility - ${make_http_plugin()} - ${make_buefy_plugin()} - ${make_buefy_components()} - - ## special global components, used by WholePage - ${self.make_menu_search_component()} - ${page_help.render_template()} - ${page_help.declare_vars()} - % if request.has_perm('common.feedback'): - ${self.make_feedback_component()} - % endif - - ## DEPRECATED; called for back-compat - ${self.render_whole_page_template()} - - ## DEPRECATED; called for back-compat - ${self.declare_whole_page_vars()} -</%def> - -## DEPRECATED; remains for back-compat -<%def name="render_whole_page_template()"> - ${self.render_vue_template_whole_page()} - ${self.render_vue_script_whole_page()} -</%def> - -<%def name="modify_vue_vars()"> - ## DEPRECATED; called for back-compat - ${self.modify_whole_page_vars()} -</%def> - -<%def name="make_vue_components()"> - ${page_help.make_component()} - ## ${multi_file_upload.make_component()} - - ## DEPRECATED; called for back-compat (?) - ${self.make_whole_page_component()} -</%def> - -## DEPRECATED; remains for back-compat <%def name="make_whole_page_component()"> + ${self.render_whole_page_template()} + ${self.declare_whole_page_vars()} + ${self.modify_whole_page_vars()} +## ${self.finalize_whole_page_vars()} + + ${page_help.make_component()} +## ${multi_file_upload.make_component()} + <script> WholePage.data = () => { return WholePageData } </script> <% request.register_component('whole-page', 'WholePage') %> </%def> -<%def name="make_vue_app()"> - ## DEPRECATED; called for back-compat - ${self.make_whole_page_app()} -</%def> - -## DEPRECATED; remains for back-compat <%def name="make_whole_page_app()"> <script type="module"> import {createApp} from 'vue' @@ -1235,11 +1223,3 @@ app.mount('#app') </script> </%def> - -############################## -## DEPRECATED -############################## - -<%def name="declare_whole_page_vars()"></%def> - -<%def name="modify_whole_page_vars()"></%def> diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako index 3a2cd798..51a0deb9 100644 --- a/tailbone/templates/themes/butterball/buefy-components.mako +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -666,7 +666,6 @@ <%def name="make_b_tooltip_component()"> <script type="text/x-template" id="b-tooltip-template"> <o-tooltip :label="label" - :position="orugaPosition" :multiline="multilined"> <slot /> </o-tooltip> @@ -677,14 +676,6 @@ props: { label: String, multilined: Boolean, - position: String, - }, - computed: { - orugaPosition() { - if (this.position) { - return this.position.replace(/^is-/, '') - } - }, }, } </script> diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako index 917083c4..d79c88f4 100644 --- a/tailbone/templates/themes/butterball/field-components.mako +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -517,9 +517,6 @@ }, parseTime(value) { - if (!value) { - return value - } if (value.getHours) { return value diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako deleted file mode 100644 index 774479ba..00000000 --- a/tailbone/templates/themes/waterpark/base.mako +++ /dev/null @@ -1,504 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/base.mako" /> -<%namespace name="base_meta" file="/base_meta.mako" /> -<%namespace file="/formposter.mako" import="declare_formposter_mixin" /> -<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" /> -<%namespace name="page_help" file="/page_help.mako" /> - -<%def name="base_styles()"> - ${parent.base_styles()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))} - <style> - - .filters .filter-fieldname .field, - .filters .filter-fieldname .field label { - width: 100%; - } - - .filters .filter-fieldname, - .filters .filter-fieldname .field label, - .filters .filter-fieldname .button { - justify-content: left; - } - - .filters .filter-verb .select, - .filters .filter-verb .select select { - width: 100%; - } - - % if filter_fieldname_width is not Undefined: - - .filters .filter-fieldname, - .filters .filter-fieldname .button { - min-width: ${filter_fieldname_width}; - } - - .filters .filter-verb { - min-width: ${filter_verb_width}; - } - - % endif - - </style> -</%def> - -<%def name="before_content()"> - ## TODO: this must come before the self.body() call..but why? - ${declare_formposter_mixin()} -</%def> - -<%def name="render_navbar_brand()"> - <div class="navbar-brand"> - <a class="navbar-item" href="${url('home')}" - v-show="!menuSearchActive"> - <div style="display: flex; align-items: center;"> - ${base_meta.header_logo()} - <div id="navbar-brand-title"> - ${base_meta.global_title()} - </div> - </div> - </a> - <div v-show="menuSearchActive" - class="navbar-item"> - <b-autocomplete ref="menuSearchAutocomplete" - v-model="menuSearchTerm" - :data="menuSearchFilteredData" - field="label" - open-on-focus - keep-first - icon-pack="fas" - clearable - @keydown.native="menuSearchKeydown" - @select="menuSearchSelect"> - </b-autocomplete> - </div> - <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false"> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - <span aria-hidden="true"></span> - </a> - </div> -</%def> - -<%def name="render_navbar_start()"> - <div class="navbar-start"> - - <div v-if="menuSearchData.length" - class="navbar-item"> - <b-button type="is-primary" - size="is-small" - @click="menuSearchInit()"> - <span><i class="fa fa-search"></i></span> - </b-button> - </div> - - % for topitem in menus: - % if topitem['is_link']: - ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')} - % else: - <div class="navbar-item has-dropdown is-hoverable"> - <a class="navbar-link">${topitem['title']}</a> - <div class="navbar-dropdown"> - % for item in topitem['items']: - % if item['is_menu']: - <% item_hash = id(item) %> - <% toggle = 'menu_{}_shown'.format(item_hash) %> - <div> - <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')"> - ${item['title']} - </a> - </div> - % for subitem in item['items']: - % if subitem['is_sep']: - <hr class="navbar-divider" v-show="${toggle}"> - % else: - ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})} - % endif - % endfor - % else: - % if item['is_sep']: - <hr class="navbar-divider"> - % else: - ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])} - % endif - % endif - % endfor - </div> - </div> - % endif - % endfor - - </div> -</%def> - -<%def name="render_theme_picker()"> - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - <div class="level-item"> - ${h.form(url('change_theme'), method="post", ref='themePickerForm')} - ${h.csrf_token(request)} - <input type="hidden" name="referrer" :value="referrer" /> - <div style="display: flex; align-items: center; gap: 0.5rem;"> - <span>Theme:</span> - <b-select name="theme" - v-model="globalTheme" - @input="changeTheme()"> - % for option in theme_picker_options: - <option value="${option.value}"> - ${option.label} - </option> - % endfor - </b-select> - </div> - ${h.end_form()} - </div> - % endif -</%def> - -<%def name="render_feedback_button()"> - - <div class="level-item"> - <page-help - % if can_edit_help: - @configure-fields-help="configureFieldsHelp = true" - % endif - /> - </div> - - ${parent.render_feedback_button()} -</%def> - -<%def name="render_crud_header_buttons()"> - % 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 -</%def> - -<%def name="render_this_page_component()"> - <this-page @change-content-title="changeContentTitle" - % if can_edit_help: - :configure-fields-help="configureFieldsHelp" - % endif - /> -</%def> - -<%def name="render_vue_template_feedback()"> - <script type="text/x-template" id="feedback-template"> - <div> - - <div class="level-item"> - <b-button type="is-primary" - @click="showFeedback()" - icon-pack="fas" - icon-left="comment"> - Feedback - </b-button> - </div> - - <b-modal has-modal-card - :active.sync="showDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">User Feedback</p> - </header> - - <section class="modal-card-body"> - <p class="block"> - Questions, suggestions, comments, complaints, etc. - <span class="red">regarding this website</span> are - welcome and may be submitted below. - </p> - - <b-field label="User Name"> - <b-input v-model="userName" - % if request.user: - disabled - % endif - > - </b-input> - </b-field> - - <b-field label="Referring URL"> - <b-input - v-model="referrer" - disabled="true"> - </b-input> - </b-field> - - <b-field label="Message"> - <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> - % endif - - </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 || !message.trim()"> - {{ sendingFeedback ? "Working, please wait..." : "Send Message" }} - </b-button> - </footer> - </div> - </b-modal> - - </div> - </script> -</%def> - -<%def name="render_vue_script_feedback()"> - ${parent.render_vue_script_feedback()} - <script> - - WuttaFeedbackForm.template = '#feedback-template' - WuttaFeedbackForm.props.message = String - - % if config.get_bool('tailbone.feedback_allows_reply'): - - WuttaFeedbackFormData.pleaseReply = false - WuttaFeedbackFormData.userEmail = null - - WuttaFeedbackForm.methods.pleaseReplyChanged = function(value) { - this.$nextTick(() => { - this.$refs.userEmail.focus() - }) - } - - WuttaFeedbackForm.methods.getExtraParams = function() { - return { - please_reply_to: this.pleaseReply ? this.userEmail : null, - } - } - - % endif - - // TODO: deprecate / remove these - 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 name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - - ############################## - ## menu search - ############################## - - WholePageData.menuSearchActive = false - WholePageData.menuSearchTerm = '' - WholePageData.menuSearchData = ${json.dumps(global_search_data or [])|n} - - WholePage.computed.menuSearchFilteredData = function() { - if (!this.menuSearchTerm.length) { - return this.menuSearchData - } - - const terms = [] - for (let term of this.menuSearchTerm.toLowerCase().split(' ')) { - term = term.trim() - if (term) { - terms.push(term) - } - } - if (!terms.length) { - return this.menuSearchData - } - - // all terms must match - return this.menuSearchData.filter((option) => { - const label = option.label.toLowerCase() - for (const term of terms) { - if (label.indexOf(term) < 0) { - return false - } - } - return true - }) - } - - WholePage.methods.globalKey = function(event) { - - // Ctrl+8 opens menu search - if (event.target.tagName == 'BODY') { - if (event.ctrlKey && event.key == '8') { - this.menuSearchInit() - } - } - } - - WholePage.mounted = function() { - window.addEventListener('keydown', this.globalKey) - for (let hook of this.mountedHooks) { - hook(this) - } - } - - WholePage.beforeDestroy = function() { - window.removeEventListener('keydown', this.globalKey) - } - - WholePage.methods.menuSearchInit = function() { - this.menuSearchTerm = '' - this.menuSearchActive = true - this.$nextTick(() => { - this.$refs.menuSearchAutocomplete.focus() - }) - } - - WholePage.methods.menuSearchKeydown = function(event) { - - // ESC will dismiss searchbox - if (event.which == 27) { - this.menuSearchActive = false - } - } - - WholePage.methods.menuSearchSelect = function(option) { - location.href = option.url - } - - ############################## - ## theme picker - ############################## - - % if expose_theme_picker and request.has_perm('common.change_app_theme'): - - WholePageData.globalTheme = ${json.dumps(theme or None)|n} - ## WholePageData.referrer = location.href - - WholePage.methods.changeTheme = function() { - this.$refs.themePickerForm.submit() - } - - % endif - - ############################## - ## edit fields help - ############################## - - % if can_edit_help: - WholePageData.configureFieldsHelp = false - % endif - - </script> -</%def> - -<%def name="make_vue_components()"> - ${parent.make_vue_components()} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + f'?ver={tailbone.__version__}')} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + f'?ver={tailbone.__version__}')} - ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.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()} - ${page_help.make_component()} -</%def> diff --git a/tailbone/templates/themes/waterpark/configure.mako b/tailbone/templates/themes/waterpark/configure.mako deleted file mode 100644 index 7a3e5261..00000000 --- a/tailbone/templates/themes/waterpark/configure.mako +++ /dev/null @@ -1,78 +0,0 @@ -## -*- coding: utf-8; -*- -<%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> diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako deleted file mode 100644 index f88d6821..00000000 --- a/tailbone/templates/themes/waterpark/form.mako +++ /dev/null @@ -1,10 +0,0 @@ -## -*- coding: utf-8; -*- -<%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> diff --git a/tailbone/templates/themes/waterpark/master/configure.mako b/tailbone/templates/themes/waterpark/master/configure.mako deleted file mode 100644 index 51da5b0a..00000000 --- a/tailbone/templates/themes/waterpark/master/configure.mako +++ /dev/null @@ -1,2 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/master/configure.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/create.mako b/tailbone/templates/themes/waterpark/master/create.mako deleted file mode 100644 index 23399b9e..00000000 --- a/tailbone/templates/themes/waterpark/master/create.mako +++ /dev/null @@ -1,2 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/master/create.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/delete.mako b/tailbone/templates/themes/waterpark/master/delete.mako deleted file mode 100644 index a15dfaf8..00000000 --- a/tailbone/templates/themes/waterpark/master/delete.mako +++ /dev/null @@ -1,46 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="tailbone:templates/form.mako" /> - -<%def name="title()">Delete ${model_title}: ${instance_title}</%def> - -<%def name="render_form()"> - <br /> - <b-notification type="is-danger" :closable="false"> - You are about to delete the following ${model_title} and all associated data: - </b-notification> - ${parent.render_form()} -</%def> - -<%def name="render_form_buttons()"> - <br /> - <b-notification type="is-danger" :closable="false"> - Are you sure about this? - </b-notification> - <br /> - - ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})} - ${h.csrf_token(request)} - <div class="buttons"> - <wutta-button once tag="a" href="${form.cancel_url}" - label="Whoops, nevermind..." /> - <b-button type="is-primary is-danger" - native-type="submit" - :disabled="formSubmitting"> - {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} - </b-button> - </div> - ${h.end_form()} -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - - ${form.vue_component}Data.formSubmitting = false - - ${form.vue_component}.methods.submitForm = function() { - this.formSubmitting = true - } - - </script> -</%def> diff --git a/tailbone/templates/themes/waterpark/master/edit.mako b/tailbone/templates/themes/waterpark/master/edit.mako deleted file mode 100644 index 18a2fa2f..00000000 --- a/tailbone/templates/themes/waterpark/master/edit.mako +++ /dev/null @@ -1,2 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/master/edit.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/form.mako b/tailbone/templates/themes/waterpark/master/form.mako deleted file mode 100644 index db56843b..00000000 --- a/tailbone/templates/themes/waterpark/master/form.mako +++ /dev/null @@ -1,2 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/master/form.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/index.mako b/tailbone/templates/themes/waterpark/master/index.mako deleted file mode 100644 index e6702599..00000000 --- a/tailbone/templates/themes/waterpark/master/index.mako +++ /dev/null @@ -1,299 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/master/index.mako" /> - -<%def name="grid_tools()"> - - ## grid totals - % if getattr(master, 'supports_grid_totals', False): - <div style="display: flex; align-items: center;"> - <b-button v-if="gridTotalsDisplay == null" - :disabled="gridTotalsFetching" - @click="gridTotalsFetch()"> - {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }} - </b-button> - <div v-if="gridTotalsDisplay != null" - class="control"> - Totals: {{ gridTotalsDisplay }} - </div> - </div> - % endif - - ## download search results - % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): - <div> - <b-button type="is-primary" - icon-pack="fas" - icon-left="download" - @click="showDownloadResultsDialog = true" - :disabled="!total"> - Download Results - </b-button> - - ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')} - ${h.csrf_token(request)} - <input type="hidden" name="fmt" :value="downloadResultsFormat" /> - <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" /> - ${h.end_form()} - - <b-modal :active.sync="showDownloadResultsDialog"> - <div class="card"> - - <div class="card-content"> - <p> - There are - <span class="is-size-4 has-text-weight-bold"> - {{ total.toLocaleString('en') }} ${model_title_plural} - </span> - matching your current filters. - </p> - <p> - You may download this set as a single data file if you like. - </p> - <br /> - - <b-notification type="is-warning" :closable="false" - v-if="downloadResultsFormat == 'xlsx' && total >= 1000"> - Excel downloads for large data sets can take a long time to - generate, and bog down the server in the meantime. You are - encouraged to choose CSV for a large data set, even though - the end result (file size) may be larger with CSV. - </b-notification> - - <div style="display: flex; justify-content: space-between"> - - <div> - <b-field label="Format"> - <b-select v-model="downloadResultsFormat"> - % for key, label in master.download_results_supported_formats().items(): - <option value="${key}">${label}</option> - % endfor - </b-select> - </b-field> - </div> - - <div> - - <div v-show="downloadResultsFieldsMode != 'choose'" - class="has-text-right"> - <p v-if="downloadResultsFieldsMode == 'default'"> - Will use DEFAULT fields. - </p> - <p v-if="downloadResultsFieldsMode == 'all'"> - Will use ALL fields. - </p> - <br /> - </div> - - <div class="buttons is-right"> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'default'" - @click="downloadResultsUseDefaultFields()"> - Use Default Fields - </b-button> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'all'" - @click="downloadResultsUseAllFields()"> - Use All Fields - </b-button> - <b-button type="is-primary" - v-show="downloadResultsFieldsMode != 'choose'" - @click="downloadResultsFieldsMode = 'choose'"> - Choose Fields - </b-button> - </div> - - <div v-show="downloadResultsFieldsMode == 'choose'"> - <div style="display: flex;"> - <div> - <b-field label="Excluded Fields"> - <b-select multiple native-size="8" - expanded - v-model="downloadResultsExcludedFieldsSelected" - ref="downloadResultsExcludedFields"> - <option v-for="field in downloadResultsFieldsExcluded" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> - </div> - <div> - <br /><br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsExcludeFields()"> - < - </b-button> - <br /> - <b-button style="margin: 0.5rem;" - @click="downloadResultsIncludeFields()"> - > - </b-button> - </div> - <div> - <b-field label="Included Fields"> - <b-select multiple native-size="8" - expanded - v-model="downloadResultsIncludedFieldsSelected" - ref="downloadResultsIncludedFields"> - <option v-for="field in downloadResultsFieldsIncluded" - :key="field" - :value="field"> - {{ field }} - </option> - </b-select> - </b-field> - </div> - </div> - </div> - - </div> - </div> - </div> <!-- card-content --> - - <footer class="modal-card-foot"> - <b-button @click="showDownloadResultsDialog = false"> - Cancel - </b-button> - <once-button type="is-primary" - @click="downloadResultsSubmit()" - icon-pack="fas" - icon-left="download" - :disabled="!downloadResultsFieldsIncluded.length" - text="Download Results"> - </once-button> - </footer> - </div> - </b-modal> - </div> - % endif - - ## download rows for search results - % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): - <b-button type="is-primary" - icon-pack="fas" - icon-left="download" - @click="downloadResultsRows()" - :disabled="downloadResultsRowsButtonDisabled"> - {{ downloadResultsRowsButtonText }} - </b-button> - ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')} - ${h.csrf_token(request)} - ${h.end_form()} - % endif - - ## merge 2 objects - % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)): - - ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} - ${h.csrf_token(request)} - <input type="hidden" - name="uuids" - :value="checkedRowUUIDs()" /> - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="object-ungroup" - :disabled="mergeFormSubmitting || checkedRows.length != 2"> - {{ mergeFormButtonText }} - </b-button> - ${h.end_form()} - % endif - - ## enable / disable selected objects - % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): - - ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} - ${h.csrf_token(request)} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button :disabled="enableSelectedDisabled" - @click="enableSelectedSubmit()"> - {{ enableSelectedText }} - </b-button> - ${h.end_form()} - - ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button :disabled="disableSelectedDisabled" - @click="disableSelectedSubmit()"> - {{ disableSelectedText }} - </b-button> - ${h.end_form()} - % endif - - ## delete selected objects - % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): - ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} - ${h.csrf_token(request)} - ${h.hidden('uuids', v_model='selected_uuids')} - <b-button type="is-danger" - :disabled="deleteSelectedDisabled" - @click="deleteSelectedSubmit()" - icon-pack="fas" - icon-left="trash"> - {{ deleteSelectedText }} - </b-button> - ${h.end_form()} - % endif - - ## delete search results - % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)): - ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} - ${h.csrf_token(request)} - <b-button type="is-danger" - :disabled="deleteResultsDisabled" - :title="total ? null : 'There are no results to delete'" - @click="deleteResultsSubmit()" - icon-pack="fas" - icon-left="trash"> - {{ deleteResultsText }} - </b-button> - ${h.end_form()} - % endif - -</%def> - -## DEPRECATED; remains for back-compat -<%def name="render_this_page()"> - ${self.page_content()} -</%def> - -<%def name="render_vue_template_grid()"> - ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - - % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'): - - ${grid.vue_component}Data.deleteResultsSubmitting = false - ${grid.vue_component}Data.deleteResultsText = "Delete Results" - - ${grid.vue_component}.computed.deleteResultsDisabled = function() { - if (this.deleteResultsSubmitting) { - return true - } - if (!this.total) { - return true - } - return false - } - - ${grid.vue_component}.methods.deleteResultsSubmit = function() { - // TODO: show "plural model title" here? - if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) { - return - } - - this.deleteResultsSubmitting = true - this.deleteResultsText = "Working, please wait..." - this.$refs.delete_results_form.submit() - } - - % endif - - </script> -</%def> diff --git a/tailbone/templates/themes/waterpark/master/view.mako b/tailbone/templates/themes/waterpark/master/view.mako deleted file mode 100644 index 99194469..00000000 --- a/tailbone/templates/themes/waterpark/master/view.mako +++ /dev/null @@ -1,2 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/master/view.mako" /> diff --git a/tailbone/templates/themes/waterpark/page.mako b/tailbone/templates/themes/waterpark/page.mako deleted file mode 100644 index 66ce47dc..00000000 --- a/tailbone/templates/themes/waterpark/page.mako +++ /dev/null @@ -1,48 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/page.mako" /> - -<%def name="render_vue_template_this_page()"> - <script type="text/x-template" id="this-page-template"> - <div style="height: 100%;"> - ## DEPRECATED; called for back-compat - ${self.render_this_page()} - </div> - </script> -</%def> - -## DEPRECATED; remains for back-compat -<%def name="render_this_page()"> - <div style="display: flex;"> - - <div class="this-page-content" style="flex-grow: 1;"> - ${self.page_content()} - </div> - - ## DEPRECATED; remains for back-compat - <ul id="context-menu"> - ${self.context_menu_items()} - </ul> - </div> -</%def> - -## DEPRECATED; remains for back-compat -<%def name="context_menu_items()"> - % if context_menu_list_items is not Undefined: - % for item in context_menu_list_items: - <li>${item}</li> - % endfor - % endif -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - - ThisPageData.csrftoken = ${json.dumps(h.get_csrf_token(request))|n} - - % if can_edit_help: - ThisPage.props.configureFieldsHelp = Boolean - % endif - - </script> -</%def> diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako index 10c57e18..4569759b 100644 --- a/tailbone/templates/trainwreck/transactions/configure.mako +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -62,9 +62,14 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + ThisPageData.hiddenDatabases = ${json.dumps(hidden_databases)|n} + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako index f26515b5..b36e7bc3 100644 --- a/tailbone/templates/trainwreck/transactions/rollover.mako +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -48,9 +48,14 @@ </b-table> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + ThisPageData.engines = ${json.dumps(engines_data)|n} + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako index 630950cf..2be51c7d 100644 --- a/tailbone/templates/trainwreck/transactions/view.mako +++ b/tailbone/templates/trainwreck/transactions/view.mako @@ -1,11 +1,15 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + % if custorder_xref_markers_data is not Undefined: - ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} + ${form.component_studly}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} % endif + </script> </%def> + +${parent.body()} diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako index 2507492e..9abcb8ba 100644 --- a/tailbone/templates/trainwreck/transactions/view_row.mako +++ b/tailbone/templates/trainwreck/transactions/view_row.mako @@ -1,11 +1,16 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + % if discounts_data is not Undefined: - ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n} + ${form.component_studly}Data.discountsData = ${json.dumps(discounts_data)|n} % endif + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako index 4815fc79..597cabfd 100644 --- a/tailbone/templates/units-of-measure/index.mako +++ b/tailbone/templates/units-of-measure/index.mako @@ -51,17 +51,20 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if master.has_perm('collect_wild_uoms'): - <script> + <script type="text/javascript"> - ${grid.vue_component}Data.showingCollectWildDialog = false + TailboneGridData.showingCollectWildDialog = false - ${grid.vue_component}.methods.collectFromWild = function() { - this.$refs['collect-wild-uoms-form'].submit() - } + TailboneGrid.methods.collectFromWild = function() { + this.$refs['collect-wild-uoms-form'].submit() + } - </script> + </script> % endif </%def> + + +${parent.body()} diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako index 9439f830..f7af685c 100644 --- a/tailbone/templates/upgrades/configure.mako +++ b/tailbone/templates/upgrades/configure.mako @@ -111,9 +111,9 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.upgradeSystems = ${json.dumps(upgrade_systems)|n} ThisPageData.upgradeSystemShowDialog = false @@ -161,3 +161,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index c3fca81d..6ae110e0 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -137,11 +137,11 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> - ${form.vue_component}Data.showingPackages = 'diffs' + TailboneFormData.showingPackages = 'diffs' % if master.has_perm('execute'): @@ -153,7 +153,7 @@ // execute upgrade ////////////////////////////// - ${form.vue_component}.props.upgradeExecuting = { + TailboneForm.props.upgradeExecuting = { type: Boolean, default: false, } @@ -253,9 +253,9 @@ // execute upgrade ////////////////////////////// - ${form.vue_component}Data.formSubmitting = false + TailboneFormData.formSubmitting = false - ${form.vue_component}.methods.submitForm = function() { + TailboneForm.methods.submitForm = function() { this.formSubmitting = true } @@ -265,12 +265,12 @@ // declare failure ////////////////////////////// - ${form.vue_component}.props.declareFailureSubmitting = { + TailboneForm.props.declareFailureSubmitting = { type: Boolean, default: false, } - ${form.vue_component}.methods.declareFailureClick = function() { + TailboneForm.methods.declareFailureClick = function() { this.$emit('declare-failure-click') } @@ -287,3 +287,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako index ecfdd1c7..c2e17396 100644 --- a/tailbone/templates/users/preferences.mako +++ b/tailbone/templates/users/preferences.mako @@ -42,9 +42,14 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n} + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index d1afd218..ed2b5f16 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -76,12 +76,12 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} % if master.has_perm('manage_api_tokens'): - <script> + <script type="text/javascript"> - ${form.vue_component}.props.apiTokens = null + ${form.component_studly}.props.apiTokens = null ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n} @@ -134,3 +134,6 @@ </script> % endif </%def> + + +${parent.body()} diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako index 6b135346..79dad455 100644 --- a/tailbone/templates/vendors/configure.mako +++ b/tailbone/templates/vendors/configure.mako @@ -44,9 +44,14 @@ </div> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> + ThisPageData.supportedVendorSettings = ${json.dumps(supported_vendor_settings)|n} + </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako index e902fd48..c5e22cfb 100644 --- a/tailbone/templates/views/model/create.mako +++ b/tailbone/templates/views/model/create.mako @@ -259,9 +259,9 @@ def includeme(config): </b-steps> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.activeStep = 'enter-details' @@ -334,3 +334,6 @@ def includeme(config): </script> </%def> + + +${parent.body()} diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako index 432e011d..8740b4c9 100644 --- a/tailbone/templates/workorders/view.mako +++ b/tailbone/templates/workorders/view.mako @@ -145,9 +145,9 @@ </nav> </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + <script type="text/javascript"> ThisPageData.receiveButtonDisabled = false ThisPageData.receiveButtonText = "I've received the order from customer" @@ -216,3 +216,6 @@ </script> </%def> + + +${parent.body()} diff --git a/tailbone/util.py b/tailbone/util.py index 71aa35e3..c1a0e1d5 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -39,12 +39,6 @@ from pyramid.renderers import get_renderer from pyramid.interfaces import IRoutesMapper from webhelpers2.html import HTML, tags -from wuttaweb.util import (get_form_data as wutta_get_form_data, - get_libver as wutta_get_libver, - get_liburl as wutta_get_liburl, - get_csrf_token as wutta_get_csrf_token, - render_csrf_token) - log = logging.getLogger(__name__) @@ -61,30 +55,37 @@ class SortColumn(object): def get_csrf_token(request): - """ """ - warnings.warn("tailbone.util.get_csrf_token() is deprecated; " - "please use wuttaweb.util.get_csrf_token() instead", - DeprecationWarning, stacklevel=2) - return wutta_get_csrf_token(request) + """ + Convenience function to retrieve the effective CSRF token for the given + request. + """ + token = request.session.get_csrf_token() + if token is None: + token = request.session.new_csrf_token() + return token def csrf_token(request, name='_csrf'): - """ """ - warnings.warn("tailbone.util.csrf_token() is deprecated; " - "please use wuttaweb.util.render_csrf_token() instead", - DeprecationWarning, stacklevel=2) - return render_csrf_token(request, name=name) + """ + Convenience function. Returns CSRF hidden tag inside hidden DIV. + """ + token = get_csrf_token(request) + return HTML.tag("div", tags.hidden(name, value=token), style="display:none;") def get_form_data(request): """ - DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()` - instead. + Returns the effective form data for the given request. Mostly + this is a convenience, to return either POST or JSON depending on + the type of request. """ - warnings.warn("tailbone.util.get_form_data() is deprecated; " - "please use wuttaweb.util.get_form_data() instead", - DeprecationWarning, stacklevel=2) - return wutta_get_form_data(request) + # nb. we prefer JSON only if no POST is present + # TODO: this seems to work for our use case at least, but perhaps + # there is a better way? see also + # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr + if (request.is_xhr or request.content_type == 'application/json') and not request.POST: + return request.json_body + return request.POST def get_global_search_options(request): @@ -104,32 +105,154 @@ def get_global_search_options(request): return options -def get_libver(request, key, fallback=True, default_only=False): # pragma: no cover +def get_libver(request, key, fallback=True, default_only=False): """ - DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_libver()` - instead. + Return the appropriate URL for the library identified by ``key``. """ - warnings.warn("tailbone.util.get_libver() is deprecated; " - "please use wuttaweb.util.get_libver() instead", - DeprecationWarning, stacklevel=2) + config = request.rattail_config - return wutta_get_libver(request, key, prefix='tailbone', - configured_only=not fallback, - default_only=default_only) + if not default_only: + version = config.get('tailbone', 'libver.{}'.format(key)) + if version: + return version + + if not fallback and not default_only: + + if key == 'buefy': + version = config.get('tailbone', 'buefy_version') + if version: + return version + + elif key == 'buefy.css': + version = get_libver(request, 'buefy', fallback=False) + if version: + return version + + elif key == 'vue': + version = config.get('tailbone', 'vue_version') + if version: + return version + + return + + if key == 'buefy': + if not default_only: + version = config.get('tailbone', 'buefy_version') + if version: + return version + return 'latest' + + elif key == 'buefy.css': + version = get_libver(request, 'buefy', default_only=default_only) + if version: + return version + return 'latest' + + elif key == 'vue': + if not default_only: + version = config.get('tailbone', 'vue_version') + if version: + return version + return '2.6.14' + + elif key == 'vue_resource': + return 'latest' + + elif key == 'fontawesome': + return '5.3.1' + + elif key == 'bb_vue': + return '3.4.31' + + elif key == 'bb_oruga': + return '0.8.12' + + elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'): + return '0.3.0' + + elif key == 'bb_fontawesome_svg_core': + return '6.5.2' + + elif key == 'bb_free_solid_svg_icons': + return '6.5.2' + + elif key == 'bb_vue_fontawesome': + return '3.0.6' -def get_liburl(request, key, fallback=True): # pragma: no cover +def get_liburl(request, key, fallback=True): """ - DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_liburl()` - instead. + Return the appropriate URL for the library identified by ``key``. """ - warnings.warn("tailbone.util.get_liburl() is deprecated; " - "please use wuttaweb.util.get_liburl() instead", - DeprecationWarning, stacklevel=2) + config = request.rattail_config - return wutta_get_liburl(request, key, prefix='tailbone', - configured_only=not fallback, - default_only=False) + url = config.get('tailbone', 'liburl.{}'.format(key)) + if url: + return url + + if not fallback: + return + + version = get_libver(request, key) + + static = config.get('tailbone.static_libcache.module') + if static: + static = importlib.import_module(static) + needed = request.environ['fanstatic.needed'] + liburl = needed.library_url(static.libcache) + '/' + # nb. add custom url prefix if needed, e.g. /theo + if request.script_name: + liburl = request.script_name + liburl + + if key == 'buefy': + return 'https://unpkg.com/buefy@{}/dist/buefy.min.js'.format(version) + + elif key == 'buefy.css': + return 'https://unpkg.com/buefy@{}/dist/buefy.min.css'.format(version) + + elif key == 'vue': + return 'https://unpkg.com/vue@{}/dist/vue.min.js'.format(version) + + elif key == 'vue_resource': + return 'https://cdn.jsdelivr.net/npm/vue-resource@{}'.format(version) + + elif key == 'fontawesome': + return 'https://use.fontawesome.com/releases/v{}/js/all.js'.format(version) + + elif key == 'bb_vue': + if static and hasattr(static, 'bb_vue_js'): + return liburl + static.bb_vue_js.relpath + return f'https://unpkg.com/vue@{version}/dist/vue.esm-browser.prod.js' + + elif key == 'bb_oruga': + if static and hasattr(static, 'bb_oruga_js'): + return liburl + static.bb_oruga_js.relpath + return f'https://unpkg.com/@oruga-ui/oruga-next@{version}/dist/oruga.mjs' + + elif key == 'bb_oruga_bulma': + if static and hasattr(static, 'bb_oruga_bulma_js'): + return liburl + static.bb_oruga_bulma_js.relpath + return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.mjs' + + elif key == 'bb_oruga_bulma_css': + if static and hasattr(static, 'bb_oruga_bulma_css'): + return liburl + static.bb_oruga_bulma_css.relpath + return f'https://unpkg.com/@oruga-ui/theme-bulma@{version}/dist/bulma.css' + + elif key == 'bb_fontawesome_svg_core': + if static and hasattr(static, 'bb_fontawesome_svg_core_js'): + return liburl + static.bb_fontawesome_svg_core_js.relpath + return f'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@{version}/+esm' + + elif key == 'bb_free_solid_svg_icons': + if static and hasattr(static, 'bb_free_solid_svg_icons_js'): + return liburl + static.bb_free_solid_svg_icons_js.relpath + return f'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@{version}/+esm' + + elif key == 'bb_vue_fontawesome': + if static and hasattr(static, 'bb_vue_fontawesome_js'): + return liburl + static.bb_vue_fontawesome_js.relpath + return f'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@{version}/+esm' def pretty_datetime(config, value): @@ -338,8 +461,8 @@ def should_use_oruga(request): supports (and therefore should use) Oruga + Vue 3 as opposed to the default of Buefy + Vue 2. """ - theme = request.registry.settings.get('tailbone.theme') - if theme and 'butterball' in theme: + theme = request.registry.settings['tailbone.theme'] + if 'butterball' in theme: return True return False diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index eceab803..730d7b6a 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -24,6 +24,8 @@ Auth Views """ +from rattail.db.auth import set_user_password + import colander from deform import widget as dfwidget from pyramid.httpexceptions import HTTPForbidden @@ -44,6 +46,28 @@ class UserLogin(colander.MappingSchema): 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): def forbidden(self): @@ -80,7 +104,6 @@ class AuthenticationView(View): form.save_label = "Login" form.show_reset = True form.show_cancel = False - form.button_icon_submit = 'user' if form.validate(): user = self.authenticate_user(form.validated['username'], form.validated['password']) @@ -94,6 +117,10 @@ class AuthenticationView(View): else: 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 # (also add key handler, so ENTER acts like TAB) dform = form.make_deform_form() @@ -106,6 +133,7 @@ class AuthenticationView(View): return { 'form': form, 'referrer': referrer, + 'image_url': image_url, 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } @@ -154,27 +182,10 @@ class AuthenticationView(View): self.request.user)) return self.redirect(self.request.get_referrer()) - 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())) - + schema = ChangePassword().bind(user=self.request.user, request=self.request) form = forms.Form(schema=schema, request=self.request) if form.validate(): - auth = self.app.get_auth_handler() - auth.set_user_password(self.request.user, form.validated['new_password']) + set_user_password(self.request.user, form.validated['new_password']) self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index c162b579..f4f74a34 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -46,11 +46,10 @@ import colander from deform import widget as dfwidget from webhelpers2.html import HTML, tags -from wuttaweb.util import render_csrf_token - from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView +from tailbone.util import csrf_token log = logging.getLogger(__name__) @@ -187,9 +186,7 @@ class BatchMasterView(MasterView): breakdown = self.make_status_breakdown(batch) factory = self.get_grid_factory() - g = factory(self.request, - key='batch_row_status_breakdown', - data=[], + g = factory('batch_row_status_breakdown', [], columns=['title', 'count']) g.set_click_handler('title', "autoFilterStatus(props.row)") kwargs['status_breakdown_data'] = breakdown @@ -384,7 +381,7 @@ class BatchMasterView(MasterView): f.set_label('executed_by', "Executed by") # notes - f.set_type('notes', 'text_wrapped') + f.set_type('notes', 'text') # if self.creating and self.request.user: # batch = fs.model @@ -442,7 +439,7 @@ class BatchMasterView(MasterView): form = [ begin_form, - render_csrf_token(self.request), + csrf_token(self.request), tags.hidden('complete', value=value), submit, tags.end_form(), @@ -696,7 +693,7 @@ class BatchMasterView(MasterView): batch = self.get_instance() # TODO: most of this logic is copied from MasterView, should refactor/merge somehow... - if 'actions' not in kwargs: + if 'main_actions' not in kwargs: actions = [] # view action @@ -717,7 +714,7 @@ class BatchMasterView(MasterView): actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump) - kwargs['actions'] = actions + kwargs['main_actions'] = actions return super().make_row_grid_kwargs(**kwargs) @@ -862,7 +859,7 @@ class BatchMasterView(MasterView): if not schema: schema = colander.Schema() - kwargs['vue_tagname'] = 'execute-form' + kwargs['component'] = 'execute-form' form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) self.configure_execute_form(form) return form diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index b6fef6c8..11031353 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -195,7 +195,6 @@ class POSBatchView(BatchMasterView): factory = self.get_grid_factory() g = factory( - self.request, key=f'{route_prefix}.taxes', data=[], columns=[ diff --git a/tailbone/views/common.py b/tailbone/views/common.py index f4d98c05..7e9ddb09 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -25,7 +25,6 @@ Various common views """ import os -import warnings from collections import OrderedDict from rattail.batch import consume_batch_id @@ -51,31 +50,13 @@ class CommonView(View): Home page view. """ app = self.get_rattail_app() - - # maybe auto-redirect anons to login if not self.request.user: - 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')) + if self.rattail_config.getbool('tailbone', 'login_is_home', default=True): + raise 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') + image_url = self.rattail_config.get( + 'tailbone', 'main_image_url', + default=self.request.static_url('tailbone:static/img/home_logo.png')) context = { 'image_url': image_url, diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 88b2519f..b0658d80 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -58,10 +58,9 @@ class View: config = self.rattail_config if config: - self.config = config - self.app = self.config.get_app() - self.model = self.app.model - self.enum = self.app.enum + app = config.get_app() + self.model = app.model + self.enum = config.get_enum() @property def rattail_config(self): diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 7e49ccef..2958a98a 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -208,7 +208,8 @@ class CustomerView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) + g.main_actions.insert(1, self.make_action( + 'view_raw', url=url, icon='eye')) g.set_link('name') g.set_link('person') @@ -470,8 +471,7 @@ class CustomerView(MasterView): factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.people', + key='{}.people'.format(route_prefix), data=[], columns=[ 'shopper_number', @@ -500,8 +500,7 @@ class CustomerView(MasterView): factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.people', + key='{}.people'.format(route_prefix), data=[], columns=[ 'full_name', @@ -513,13 +512,13 @@ class CustomerView(MasterView): ) if self.request.has_perm('people.view'): - g.actions.append(self.make_action('view', icon='eye')) + g.main_actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('people.edit'): - g.actions.append(self.make_action('edit', icon='edit')) + g.main_actions.append(self.make_action('edit', icon='edit')) if self.people_detachable and self.has_perm('detach_person'): - g.actions.append(self.make_action('detach', icon='minus-circle', - link_class='has-text-warning', - click_handler="$emit('detach-person', props.row._action_url_detach)")) + g.main_actions.append(self.make_action('detach', icon='minus-circle', + link_class='has-text-warning', + click_handler="$emit('detach-person', props.row._action_url_detach)")) return HTML.literal( g.render_table_element(data_prop='peopleData')) diff --git a/tailbone/views/custorders/items.py b/tailbone/views/custorders/items.py index e7edf3aa..d8e39f55 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -385,7 +385,6 @@ class CustomerOrderItemView(MasterView): factory = self.get_grid_factory() g = factory( - self.request, key=f'{route_prefix}.events', data=[], columns=[ diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index b1a9831a..f76d4d93 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -29,12 +29,13 @@ import logging from sqlalchemy import orm -from rattail.db.model import CustomerOrder, CustomerOrderItem -from rattail.util import simple_error +from rattail.db import model +from rattail.util import pretty_quantity, simple_error from rattail.batch import get_batch_handler from webhelpers2.html import tags, HTML +from tailbone.db import Session from tailbone.views import MasterView @@ -45,7 +46,7 @@ class CustomerOrderView(MasterView): """ Master view for customer orders """ - model_class = CustomerOrder + model_class = model.CustomerOrder route_prefix = 'custorders' editable = False configurable = True @@ -79,7 +80,7 @@ class CustomerOrderView(MasterView): ] has_rows = True - model_row_class = CustomerOrderItem + model_row_class = model.CustomerOrderItem rows_viewable = False row_labels = { @@ -115,17 +116,15 @@ class CustomerOrderView(MasterView): ] def __init__(self, request): - super().__init__(request) + super(CustomerOrderView, self).__init__(request) self.batch_handler = self.get_batch_handler() def query(self, session): - model = self.app.model return session.query(model.CustomerOrder)\ .options(orm.joinedload(model.CustomerOrder.customer)) def configure_grid(self, g): super().configure_grid(g) - model = self.app.model # id g.set_link('id') @@ -164,7 +163,7 @@ class CustomerOrderView(MasterView): return f"#{order.id} for {order.customer or order.person}" def configure_form(self, f): - super().configure_form(f) + super(CustomerOrderView, self).configure_form(f) order = f.model_instance f.set_readonly('id') @@ -234,7 +233,6 @@ class CustomerOrderView(MasterView): class_='has-background-warning') def get_row_data(self, order): - model = self.app.model return self.Session.query(model.CustomerOrderItem)\ .filter(model.CustomerOrderItem.order == order) @@ -242,13 +240,11 @@ class CustomerOrderView(MasterView): return item.order def make_row_grid_kwargs(self, **kwargs): - kwargs = super().make_row_grid_kwargs(**kwargs) + kwargs = super(CustomerOrderView, self).make_row_grid_kwargs(**kwargs) - actions = kwargs.get('actions', []) - if not actions: - actions.append(self.make_action('view', icon='eye', - url=self.row_view_action_url)) - kwargs['actions'] = actions + assert not kwargs['main_actions'] + kwargs['main_actions'].append( + self.make_action('view', icon='eye', url=self.row_view_action_url)) return kwargs @@ -257,7 +253,7 @@ class CustomerOrderView(MasterView): return self.request.route_url('custorders.items.view', uuid=item.uuid) def configure_row_grid(self, g): - super().configure_row_grid(g) + super(CustomerOrderView, self).configure_row_grid(g) app = self.get_rattail_app() handler = app.get_batch_handler( 'custorder', @@ -427,7 +423,6 @@ class CustomerOrderView(MasterView): if not user: raise RuntimeError("this feature requires a user to be logged in") - model = self.app.model try: # there should be at most *one* new batch per user batch = self.Session.query(model.CustomerOrderBatch)\ @@ -493,7 +488,6 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a customer UUID"} - model = self.app.model customer = self.Session.get(model.Customer, uuid) if not customer: return {'error': "Customer not found"} @@ -514,7 +508,6 @@ class CustomerOrderView(MasterView): return info def assign_contact(self, batch, data): - model = self.app.model kwargs = {} # this will either be a Person or Customer UUID @@ -669,7 +662,6 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a product UUID"} - model = self.app.model product = self.Session.get(model.Product, uuid) if not product: return {'error': "Product not found"} @@ -733,7 +725,8 @@ class CustomerOrderView(MasterView): return app.render_currency(obj.unit_price) def normalize_row(self, row): - products_handler = self.app.get_products_handler() + app = self.get_rattail_app() + products_handler = app.get_products_handler() data = { 'uuid': row.uuid, @@ -749,20 +742,20 @@ class CustomerOrderView(MasterView): 'product_size': row.product_size, 'product_weighed': row.product_weighed, - 'case_quantity': self.app.render_quantity(row.case_quantity), - 'cases_ordered': self.app.render_quantity(row.cases_ordered), - 'units_ordered': self.app.render_quantity(row.units_ordered), - 'order_quantity': self.app.render_quantity(row.order_quantity), + 'case_quantity': pretty_quantity(row.case_quantity), + 'cases_ordered': pretty_quantity(row.cases_ordered), + 'units_ordered': pretty_quantity(row.units_ordered), + 'order_quantity': pretty_quantity(row.order_quantity), 'order_uom': row.order_uom, 'order_uom_choices': self.uom_choices_for_row(row), - 'discount_percent': self.app.render_quantity(row.discount_percent), + 'discount_percent': pretty_quantity(row.discount_percent), 'department_display': row.department_name, 'unit_price': float(row.unit_price) if row.unit_price is not None else None, 'unit_price_display': self.get_unit_price_display(row), 'total_price': float(row.total_price) if row.total_price is not None else None, - 'total_price_display': self.app.render_currency(row.total_price), + 'total_price_display': app.render_currency(row.total_price), 'status_code': row.status_code, 'status_text': row.status_text, @@ -770,15 +763,15 @@ class CustomerOrderView(MasterView): if row.unit_regular_price: data['unit_regular_price'] = float(row.unit_regular_price) - data['unit_regular_price_display'] = self.app.render_currency(row.unit_regular_price) + data['unit_regular_price_display'] = app.render_currency(row.unit_regular_price) if row.unit_sale_price: data['unit_sale_price'] = float(row.unit_sale_price) - data['unit_sale_price_display'] = self.app.render_currency(row.unit_sale_price) + data['unit_sale_price_display'] = app.render_currency(row.unit_sale_price) if row.sale_ends: - sale_ends = self.app.localtime(row.sale_ends, from_utc=True).date() + sale_ends = app.localtime(row.sale_ends, from_utc=True).date() data['sale_ends'] = str(sale_ends) - data['sale_ends_display'] = self.app.render_date(sale_ends) + data['sale_ends_display'] = app.render_date(sale_ends) if row.unit_sale_price and row.unit_price == row.unit_sale_price: data['pricing_reflects_sale'] = True @@ -815,12 +808,12 @@ class CustomerOrderView(MasterView): case_price = self.batch_handler.get_case_price_for_row(row) data['case_price'] = float(case_price) if case_price is not None else None - data['case_price_display'] = self.app.render_currency(case_price) + data['case_price_display'] = app.render_currency(case_price) if self.batch_handler.product_price_may_be_questionable(): data['price_needs_confirmation'] = row.price_needs_confirmation - key = self.app.get_product_key_field() + key = app.get_product_key_field() if key == 'upc': data['product_key'] = data['product_upc_pretty'] elif key == 'item_id': @@ -844,7 +837,7 @@ class CustomerOrderView(MasterView): case_qty = unit_qty = '??' else: case_qty = data['case_quantity'] - unit_qty = self.app.render_quantity(row.order_quantity * row.case_quantity) + unit_qty = pretty_quantity(row.order_quantity * row.case_quantity) data.update({ 'order_quantity_display': "{} {} (× {} {} = {} {})".format( data['order_quantity'], @@ -857,14 +850,14 @@ class CustomerOrderView(MasterView): else: data.update({ 'order_quantity_display': "{} {}".format( - self.app.render_quantity(row.order_quantity), + pretty_quantity(row.order_quantity), self.enum.UNIT_OF_MEASURE[unit_uom]), }) return data def add_item(self, batch, data): - model = self.app.model + app = self.get_rattail_app() order_quantity = decimal.Decimal(data.get('order_quantity') or '0') order_uom = data.get('order_uom') @@ -895,7 +888,7 @@ class CustomerOrderView(MasterView): pending_info = dict(data['pending_product']) if 'upc' in pending_info: - pending_info['upc'] = self.app.make_gpc(pending_info['upc']) + pending_info['upc'] = app.make_gpc(pending_info['upc']) for field in ('unit_cost', 'regular_price_amount', 'case_size'): if field in pending_info: @@ -924,7 +917,6 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a row UUID"} - model = self.app.model row = self.Session.get(model.CustomerOrderBatchRow, uuid) if not row: return {'error': "Row not found"} @@ -983,7 +975,6 @@ class CustomerOrderView(MasterView): if not uuid: return {'error': "Must specify a row UUID"} - model = self.app.model row = self.Session.get(model.CustomerOrderBatchRow, uuid) if not row: return {'error': "Row not found"} diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 2b955b5f..134d6018 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -202,36 +202,10 @@ class DataSyncThreadView(MasterView): return self.redirect(self.request.get_referrer( default=self.request.route_url('datasyncchanges'))) - 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) - + def configure_get_context(self): profiles = self.datasync_handler.get_configured_profiles( include_disabled=True, ignore_problems=True) - context['profiles'] = profiles profiles_data = [] for profile in sorted(profiles.values(), key=lambda p: p.key): @@ -269,15 +243,25 @@ class DataSyncThreadView(MasterView): data['consumers_data'] = consumers profiles_data.append(data) - context['profiles_data'] = profiles_data - return context + return { + 'profiles': profiles, + '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, **kwargs): - """ """ - settings = super().configure_gather_settings(data, **kwargs) + def configure_gather_settings(self, data): + settings = [] + watch = [] - if data.get('rattail.datasync.use_profile_settings') == 'true': - watch = [] + use_profile_settings = data.get('use_profile_settings') == 'true' + settings.append({'name': 'rattail.datasync.use_profile_settings', + 'value': 'true' if use_profile_settings else 'false'}) + + if use_profile_settings: for profile in json.loads(data['profiles']): pkey = profile['key'] @@ -339,12 +323,17 @@ class DataSyncThreadView(MasterView): settings.append({'name': 'rattail.datasync.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 - def configure_remove_settings(self, **kwargs): - """ """ - super().configure_remove_settings(**kwargs) - + def configure_remove_settings(self): purge_datasync_settings(self.rattail_config, self.Session()) @classmethod diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 47de8dca..6ee1439f 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -128,8 +128,8 @@ class DepartmentView(MasterView): factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.employees', + key='{}.employees'.format(route_prefix), + request=self.request, data=[], columns=[ 'first_name', @@ -140,9 +140,9 @@ class DepartmentView(MasterView): ) if self.request.has_perm('employees.view'): - g.actions.append(self.make_action('view', icon='eye')) + g.main_actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('employees.edit'): - g.actions.append(self.make_action('edit', icon='edit')) + g.main_actions.append(self.make_action('edit', icon='edit')) return HTML.literal( g.render_table_element(data_prop='employeesData')) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 98bd4295..4014c05e 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -116,12 +116,11 @@ class EmailSettingView(MasterView): return data def configure_grid(self, g): - super().configure_grid(g) - - g.sort_on_backend = False - g.sort_multiple = False + g.sorters['key'] = g.make_simple_sorter('key', foldcase=True) + g.sorters['prefix'] = g.make_simple_sorter('prefix', foldcase=True) + g.sorters['subject'] = g.make_simple_sorter('subject', foldcase=True) + g.sorters['enabled'] = g.make_simple_sorter('enabled') g.set_sort_defaults('key') - g.set_type('enabled', 'boolean') g.set_link('key') g.set_link('subject') @@ -131,16 +130,18 @@ class EmailSettingView(MasterView): # to g.set_renderer('to', self.render_to_short) + g.sorters['to'] = g.make_simple_sorter('to', foldcase=True) # hidden if self.has_perm('configure'): + g.sorters['hidden'] = g.make_simple_sorter('hidden') g.set_type('hidden', 'boolean') else: g.remove('hidden') # toggle hidden if self.has_perm('configure'): - g.actions.append( + g.main_actions.append( self.make_action('toggle_hidden', url='#', icon='ban', click_handler='toggleHidden(props.row)', factory=ToggleHidden)) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index debd8fcb..f4f99058 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -167,7 +167,8 @@ class EmployeeView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) + g.main_actions.insert(1, self.make_action( + 'view_raw', url=url, icon='eye')) def default_view_url(self): if (self.request.has_perm('people.view_profile') diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 21a5e58f..1e917902 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -39,9 +39,8 @@ from sqlalchemy import orm import sqlalchemy_continuum as continuum from sqlalchemy_utils.functions import get_primary_keys, get_columns -from wuttjamaican.util import get_class_hierarchy from rattail.db.continuum import model_transaction_query -from rattail.util import simple_error +from rattail.util import simple_error, get_class_hierarchy from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter from rattail.excel import ExcelWriter @@ -117,7 +116,6 @@ class MasterView(View): supports_prev_next = False supports_import_batch_from_file = False has_input_file_templates = False - has_output_file_templates = False configurable = False # set to True to add "View *global* Objects" permission, and @@ -138,7 +136,6 @@ class MasterView(View): deleting = False executing = False cloning = False - configuring = False has_pk_fields = False has_image = False has_thumbnail = False @@ -335,7 +332,7 @@ class MasterView(View): # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. - if self.request.GET.get('reset-view'): + if self.request.GET.get('reset-to-default-filters') == 'true': kw = {'_query': None} hash_ = self.request.GET.get('hash') if hash_: @@ -343,16 +340,14 @@ class MasterView(View): return self.redirect(self.request.current_route_url(**kw)) # Stash some grid stats, for possible use when generating URLs. - if grid.paginated and hasattr(grid, 'pager'): + if grid.pageable and hasattr(grid, 'pager'): self.first_visible_grid_index = grid.pager.first_item # return grid data only, if partial page was requested - if self.request.GET.get('partial'): - context = grid.get_table_data() - return self.json_response(context) + if self.request.params.get('partial'): + return self.json_response(grid.get_table_data()) context = { - 'index_url': None, # nb. avoid title link since this *is* the index 'grid': grid, } @@ -383,7 +378,7 @@ class MasterView(View): grid contents etc. """ - def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs): + def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Creates a new grid instance """ @@ -392,12 +387,13 @@ class MasterView(View): if key is None: key = self.get_grid_key() if data is None: - data = self.get_data(session=session) + data = self.get_data(session=kwargs.get('session')) if columns is None: columns = self.get_grid_columns() + kwargs.setdefault('request', self.request) kwargs = self.make_grid_kwargs(**kwargs) - grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) + grid = factory(key, data, columns, **kwargs) self.configure_grid(grid) grid.load_settings() return grid @@ -410,9 +406,9 @@ class MasterView(View): """ if session is None: session = self.Session() - kwargs.setdefault('paginated', False) + kwargs.setdefault('pageable', False) grid = self.make_grid(session=session, **kwargs) - return grid.get_visible_data() + return grid.make_visible_data() def get_grid_columns(self): """ @@ -443,8 +439,7 @@ class MasterView(View): 'filterable': self.filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.sortable, - 'sort_multiple': not self.request.use_oruga, - 'paginated': self.pageable, + 'pageable': self.pageable, 'extra_row_class': self.grid_extra_class, 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': checkboxes, @@ -458,26 +453,10 @@ class MasterView(View): if self.sortable or self.pageable or self.filterable: defaults['expose_direct_link'] = True - if 'actions' not in kwargs: - - if 'main_actions' in kwargs: - warnings.warn("main_actions param is deprecated for make_grid_kwargs(); " - "please use actions param instead", - DeprecationWarning, stacklevel=2) - main = kwargs.pop('main_actions') - else: - main = self.get_main_actions() - - if 'more_actions' in kwargs: - warnings.warn("more_actions param is deprecated for make_grid_kwargs(); " - "please use actions param instead", - DeprecationWarning, stacklevel=2) - more = kwargs.pop('more_actions') - else: - more = self.get_more_actions() - - defaults['actions'] = main + more - + if 'main_actions' not in kwargs and 'more_actions' not in kwargs: + main, more = self.get_grid_actions() + defaults['main_actions'] = main + defaults['more_actions'] = more defaults.update(kwargs) return defaults @@ -551,8 +530,7 @@ class MasterView(View): def get_quickie_result_url(self, obj): return self.get_action_url('view', obj) - def make_row_grid(self, factory=None, key=None, data=None, columns=None, - session=None, **kwargs): + def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Make and return a new (configured) rows grid instance. """ @@ -569,8 +547,9 @@ class MasterView(View): if columns is None: columns = self.get_row_grid_columns() + kwargs.setdefault('request', self.request) kwargs = self.make_row_grid_kwargs(**kwargs) - grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) + grid = factory(key, data, columns, **kwargs) self.configure_row_grid(grid) grid.load_settings() return grid @@ -589,16 +568,15 @@ class MasterView(View): 'filterable': self.rows_filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.rows_sortable, - 'sort_multiple': not self.request.use_oruga, - 'paginated': self.rows_pageable, + 'pageable': self.rows_pageable, 'extra_row_class': self.row_grid_extra_class, 'url': lambda obj: self.get_row_action_url('view', obj), } if self.rows_default_pagesize: - defaults['pagesize'] = self.rows_default_pagesize + defaults['default_pagesize'] = self.rows_default_pagesize - if self.has_rows and 'actions' not in defaults: + if self.has_rows and 'main_actions' not in defaults: actions = [] # view action @@ -613,12 +591,10 @@ class MasterView(View): # delete action if self.rows_deletable and self.has_perm('delete_row'): - actions.append(self.make_action('delete', icon='trash', - url=self.row_delete_action_url, - link_class='has-text-danger')) + actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) defaults['delete_speedbump'] = self.rows_deletable_speedbump - defaults['actions'] = actions + defaults['main_actions'] = actions defaults.update(kwargs) return defaults @@ -653,8 +629,9 @@ class MasterView(View): if columns is None: columns = self.get_version_grid_columns() + kwargs.setdefault('request', self.request) kwargs = self.make_version_grid_kwargs(**kwargs) - grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) + grid = factory(key, data, columns, **kwargs) self.configure_version_grid(grid) grid.load_settings() return grid @@ -680,12 +657,12 @@ class MasterView(View): defaults = { 'model_class': continuum.transaction_class(self.get_model_class()), 'width': 'full', - 'paginated': True, + 'pageable': True, 'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id), } - if 'actions' not in kwargs: + if 'main_actions' not in kwargs: url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) - defaults['actions'] = [ + defaults['main_actions'] = [ self.make_action('view', icon='eye', url=url), ] defaults.update(kwargs) @@ -903,7 +880,7 @@ class MasterView(View): def valid_employee_uuid(self, node, value): if value: - model = self.app.model + model = self.model employee = self.Session.get(model.Employee, value) if not employee: node.raise_invalid("Employee not found") @@ -939,7 +916,7 @@ class MasterView(View): def valid_vendor_uuid(self, node, value): if value: - model = self.app.model + model = self.model vendor = self.Session.get(model.Vendor, value) if not vendor: node.raise_invalid("Vendor not found") @@ -1187,7 +1164,7 @@ class MasterView(View): # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. - if self.request.GET.get('reset-view'): + if self.request.GET.get('reset-to-default-filters') == 'true': kw = {'_query': None} hash_ = self.request.GET.get('hash') if hash_: @@ -1382,19 +1359,19 @@ class MasterView(View): return classes def make_revisions_grid(self, obj, empty_data=False): - model = self.app.model + model = self.model route_prefix = self.get_route_prefix() row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, txnid=txn.id) kwargs = { - 'vue_tagname': 'versions-grid', + 'component': 'versions-grid', 'ajax_data_url': self.get_action_url('revisions_data', obj), 'sortable': True, - 'sort_multiple': not self.request.use_oruga, - 'sort_defaults': ('changed', 'desc'), - 'actions': [ + 'default_sortkey': 'changed', + 'default_sortdir': 'desc', + 'main_actions': [ self.make_action('view', icon='eye', url='#', click_handler='viewRevision(props.row)'), self.make_action('view_separate', url=row_url, target='_blank', @@ -1707,10 +1684,10 @@ class MasterView(View): """ if session is None: session = self.Session() - kwargs.setdefault('paginated', False) + kwargs.setdefault('pageable', False) kwargs.setdefault('sortable', sort) grid = self.make_row_grid(session=session, **kwargs) - return grid.get_visible_data() + return grid.make_visible_data() @classmethod def get_row_url_prefix(cls): @@ -1824,26 +1801,6 @@ class MasterView(View): path = os.path.join(basedir, filespec) 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): """ View for editing an existing model record. @@ -1905,7 +1862,6 @@ class MasterView(View): return self.redirect(self.get_action_url('view', instance)) form = self.make_form(instance) - form.save_label = "DELETE Forever" # TODO: Add better validation, ideally CSRF etc. if self.request.method == 'POST': @@ -2153,7 +2109,7 @@ class MasterView(View): Thread target for executing an object. """ app = self.get_rattail_app() - model = self.app.model + model = self.model session = app.make_session() obj = self.get_instance_for_key(key, session) user = session.get(model.User, user_uuid) @@ -2592,12 +2548,11 @@ class MasterView(View): so if you like you can return a different help URL depending on which type of CRUD view is in effect, etc. """ - # nb. self.Session may differ, so use tailbone.db.Session - session = Session() - model = self.app.model + model = self.model route_prefix = self.get_route_prefix() - info = session.query(model.TailbonePageHelp)\ + # nb. self.Session may differ, so use tailbone.db.Session + info = Session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if info and info.help_url: @@ -2615,12 +2570,11 @@ class MasterView(View): """ Return the markdown help text for current page, if defined. """ - # nb. self.Session may differ, so use tailbone.db.Session - session = Session() - model = self.app.model + model = self.model route_prefix = self.get_route_prefix() - info = session.query(model.TailbonePageHelp)\ + # nb. self.Session may differ, so use tailbone.db.Session + info = Session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if info and info.markdown_text: @@ -2637,9 +2591,7 @@ class MasterView(View): if not self.can_edit_help(): raise self.forbidden() - # nb. self.Session may differ, so use tailbone.db.Session - session = Session() - model = self.app.model + model = self.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2656,12 +2608,13 @@ class MasterView(View): if not form.validate(): return {'error': "Form did not validate"} - info = session.query(model.TailbonePageHelp)\ + # nb. self.Session may differ, so use tailbone.db.Session + info = Session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if not info: info = model.TailbonePageHelp(route_prefix=route_prefix) - session.add(info) + Session.add(info) info.help_url = form.validated['help_url'] info.markdown_text = form.validated['markdown_text'] @@ -2671,9 +2624,7 @@ class MasterView(View): if not self.can_edit_help(): raise self.forbidden() - # nb. self.Session may differ, so use tailbone.db.Session - session = Session() - model = self.app.model + model = self.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2689,14 +2640,15 @@ class MasterView(View): if not form.validate(): return {'error': "Form did not validate"} - info = session.query(model.TailboneFieldInfo)\ + # nb. self.Session may differ, so use tailbone.db.Session + info = Session.query(model.TailboneFieldInfo)\ .filter(model.TailboneFieldInfo.route_prefix == route_prefix)\ .filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\ .first() if not info: info = model.TailboneFieldInfo(route_prefix=route_prefix, field_name=form.validated['field_name']) - session.add(info) + Session.add(info) info.markdown_text = form.validated['markdown_text'] return {'ok': True} @@ -2872,12 +2824,6 @@ class MasterView(View): kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl) 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 def get_input_file_templates(self): @@ -2952,81 +2898,6 @@ class MasterView(View): 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): """ Method stub, so subclass can always invoke super() for it. @@ -3074,12 +2945,6 @@ class MasterView(View): items.append(tags.link_to(f"Download {template['label']} Template", 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: # # # TODO: either make this configurable, or just lose it. @@ -3245,11 +3110,6 @@ class MasterView(View): return key def get_grid_actions(self): - """ """ - warnings.warn("get_grid_actions() method is deprecated; " - "please use get_main_actions() or get_more_actions() instead", - DeprecationWarning, stacklevel=2) - main, more = self.get_main_actions(), self.get_more_actions() if len(more) == 1: main, more = main + more, [] @@ -3325,7 +3185,7 @@ class MasterView(View): url=self.default_clone_url) def make_grid_action_delete(self): - kwargs = {'link_class': 'has-text-danger'} + kwargs = {} if self.delete_confirm == 'simple': kwargs['click_handler'] = 'deleteObject' return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs) @@ -3359,18 +3219,14 @@ class MasterView(View): def make_action(self, key, url=None, factory=None, **kwargs): """ - Make and return a new :class:`~tailbone.grids.core.GridAction` - instance. - - This can be called to make actions for any grid, not just the - one from :meth:`index()`. + Make a new :class:`GridAction` instance for the current grid. """ if url is None: route = '{}.{}'.format(self.get_route_prefix(), key) url = lambda r, i: self.request.route_url(route, **self.get_action_route_kwargs(r)) if not factory: factory = grids.GridAction - return factory(self.request, key, url=url, **kwargs) + return factory(key, url=url, **kwargs) def get_action_route_kwargs(self, obj): """ @@ -4565,7 +4421,7 @@ class MasterView(View): 'request': self.request, 'readonly': self.viewing, 'model_class': getattr(self, 'model_class', None), - 'action_url': self.request.path_url, + 'action_url': self.request.current_route_url(_query=None), 'assume_local_times': self.has_local_times, 'route_prefix': route_prefix, 'can_edit_help': self.can_edit_help(), @@ -5233,7 +5089,6 @@ class MasterView(View): """ Generic view for configuring some aspect of the software. """ - self.configuring = True app = self.get_rattail_app() if self.request.method == 'POST': if self.request.POST.get('remove_settings'): @@ -5315,39 +5170,6 @@ class MasterView(View): data[template['setting_file']] = os.path.join(numdir, 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): """ If you have some "simple" settings, each of which basically @@ -5392,8 +5214,7 @@ class MasterView(View): simple['option']) def configure_get_context(self, simple_settings=None, - input_file_templates=True, - output_file_templates=True): + input_file_templates=True): """ Returns the full context dict, for rendering the configure page template. @@ -5442,7 +5263,7 @@ class MasterView(View): for template in self.normalize_input_file_templates( include_file_options=True): settings[template['setting_mode']] = template['mode'] - settings[template['setting_file']] = template['file'] or '' + settings[template['setting_file']] = template['file'] settings[template['setting_url']] = template['url'] file_options[template['key']] = template['file_options'] file_option_dirs[template['key']] = template['file_options_dir'] @@ -5450,27 +5271,10 @@ class MasterView(View): context['input_file_options'] = file_options 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 def configure_gather_settings(self, data, simple_settings=None, - input_file_templates=True, - output_file_templates=True): + input_file_templates=True): settings = [] # maybe collect "simple" settings @@ -5516,32 +5320,12 @@ class MasterView(View): settings.append({'name': 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 def configure_remove_settings(self, simple_settings=None, - input_file_templates=True, - output_file_templates=True): + input_file_templates=True): app = self.get_rattail_app() - model = self.app.model + model = self.model names = [] if simple_settings is None: @@ -5558,14 +5342,6 @@ class MasterView(View): 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: # nb. using thread-local session here; we do not use # self.Session b/c it may not point to Rattail @@ -5828,15 +5604,6 @@ class MasterView(View): route_name='{}.download_input_file_template'.format(route_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 if cls.viewable: cls._defaults_view(config) @@ -6100,7 +5867,7 @@ class MasterView(View): renderer='json') -class ViewSupplement: +class ViewSupplement(object): """ Base class for view "supplements" - which are sort of like plugins which can "supplement" certain aspects of the view. @@ -6127,7 +5894,6 @@ class ViewSupplement: def __init__(self, master): self.master = master self.request = master.request - self.app = master.app self.model = master.model self.rattail_config = master.rattail_config self.Session = master.Session @@ -6161,7 +5927,7 @@ class ViewSupplement: This is accomplished by subjecting the current base query to a join, e.g. something like:: - model = self.app.model + model = self.model query = query.outerjoin(model.MyExtension) return query """ diff --git a/tailbone/views/members.py b/tailbone/views/members.py index 46ed7e4b..de844eb7 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -229,7 +229,8 @@ class MemberView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) + g.main_actions.insert(1, self.make_action( + 'view_raw', url=url, icon='eye')) # equity_total # TODO: should make this configurable diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 405b1ca3..9b28b94d 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -175,7 +175,8 @@ class PersonView(MasterView): url = lambda r, i: self.request.route_url( f'{route_prefix}.view', **self.get_action_route_kwargs(r)) # nb. insert to slot 1, just after normal View action - g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) + g.main_actions.insert(1, self.make_action( + 'view_raw', url=url, icon='eye')) g.set_link('display_name') g.set_link('first_name') @@ -521,9 +522,9 @@ class PersonView(MasterView): data = self.profile_transactions_query(person) factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.profile.transactions.{person.uuid}', - data=data, + f'{route_prefix}.profile.transactions.{person.uuid}', + data, + request=self.request, model_class=model.Transaction, ajax_data_url=self.get_action_url('view_profile_transactions', person), columns=[ @@ -543,7 +544,7 @@ class PersonView(MasterView): }, filterable=True, sortable=True, - paginated=True, + pageable=True, default_sortkey='end_time', default_sortdir='desc', component='transactions-grid', @@ -551,7 +552,7 @@ class PersonView(MasterView): if self.request.has_perm('trainwreck.transactions.view'): url = lambda row, i: self.request.route_url('trainwreck.transactions.view', uuid=row.uuid) - g.actions.append(self.make_action('view', icon='eye', url=url)) + g.main_actions.append(grids.GridAction('view', icon='eye', url=url)) g.load_settings() g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) @@ -564,19 +565,15 @@ class PersonView(MasterView): Method which must return the base query for the profile's POS Transactions grid data. """ - customer = self.app.get_customer(person) + app = self.get_rattail_app() + customer = app.get_customer(person) - if customer: - key_field = self.app.get_customer_key_field() - customer_key = getattr(customer, key_field) - 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 + key_field = app.get_customer_key_field() + customer_key = getattr(customer, key_field) + if customer_key is not None: + customer_key = str(customer_key) - trainwreck = self.app.get_trainwreck_handler() + trainwreck = app.get_trainwreck_handler() model = trainwreck.get_model() query = TrainwreckSession.query(model.Transaction)\ .filter(model.Transaction.customer_id == customer_key) @@ -1386,8 +1383,8 @@ class PersonView(MasterView): } if not context['users']: - context['suggested_username'] = auth.make_unique_username(self.Session(), - person=person) + context['suggested_username'] = auth.generate_unique_username(self.Session(), + person=person) return context @@ -1416,9 +1413,9 @@ class PersonView(MasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.profile.revisions', - data=[], # start with empty data! + '{}.profile.revisions'.format(route_prefix), + [], # start with empty data! + request=self.request, columns=[ 'changed', 'changed_by', @@ -1433,7 +1430,7 @@ class PersonView(MasterView): 'changed_by', 'comment', ], - actions=[ + main_actions=[ self.make_action('view', icon='eye', url='#', click_handler='viewRevision(props.row)'), ], @@ -2190,8 +2187,4 @@ def defaults(config, **kwargs): def includeme(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.people') - else: - defaults(config) + defaults(config) diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index ded80b18..462df51d 100644 --- a/tailbone/views/poser/reports.py +++ b/tailbone/views/poser/reports.py @@ -110,7 +110,7 @@ class PoserReportView(PoserMasterView): g.set_searchable('description') if self.request.has_perm('report_output.create'): - g.actions.append(self.make_action( + g.more_actions.append(self.make_action( 'generate', icon='arrow-circle-right', url=self.get_generate_url)) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index 3986f8b0..fb09306b 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -54,7 +54,7 @@ class PrincipalMasterView(MasterView): View for finding all users who have been granted a given permission """ permissions = copy.deepcopy( - self.request.registry.settings.get('wutta_permissions', {})) + self.request.registry.settings.get('tailbone_permissions', {})) # sort groups, and permissions for each group, for UI's sake sorted_perms = sorted(permissions.items(), key=self.perm_sortkey) @@ -124,11 +124,11 @@ class PrincipalMasterView(MasterView): def find_by_perm_make_results_grid(self, principals): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() - g = factory(self.request, - key=f'{route_prefix}.results', + g = factory(key=f'{route_prefix}.results', + request=self.request, data=[], columns=[], - actions=[ + main_actions=[ self.make_action('view', icon='eye', click_handler='navigateTo(props.row._url)'), ]) @@ -194,7 +194,7 @@ class PermissionsRenderer(Object): rendered = False for key in sorted(perms, key=lambda p: perms[p]['label'].lower()): checked = auth.has_permission(Session(), principal, key, - include_anonymous=self.include_guest, + include_guest=self.include_guest, include_authenticated=self.include_authenticated) if checked: label = perms[key]['label'] diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8461ae03..bf2d7f14 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -34,7 +34,7 @@ import sqlalchemy_continuum as continuum from rattail import enum, pod, sil from rattail.db import api, auth, Session as RattailSession -from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem +from rattail.db.model import Product, PendingProduct, CustomerOrderItem from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError @@ -384,7 +384,7 @@ class ProductView(MasterView): g.set_filter('report_code_name', model.ReportCode.name) if self.expose_label_printing and self.has_perm('print_labels'): - g.actions.append(self.make_action( + g.more_actions.append(self.make_action( 'print_label', icon='print', url='#', click_handler='quickLabelPrint(props.row)')) @@ -1197,9 +1197,8 @@ class ProductView(MasterView): # regular price data = [] # defer fetching until user asks for it - grid = grids.Grid(self.request, - key='products.regular_price_history', - data=data, + grid = grids.Grid('products.regular_price_history', data, + request=self.request, columns=[ 'price', 'since', @@ -1212,9 +1211,8 @@ class ProductView(MasterView): # current price data = [] # defer fetching until user asks for it - grid = grids.Grid(self.request, - key='products.current_price_history', - data=data, + grid = grids.Grid('products.current_price_history', data, + request=self.request, columns=[ 'price', 'price_type', @@ -1231,9 +1229,8 @@ class ProductView(MasterView): # suggested price data = [] # defer fetching until user asks for it - grid = grids.Grid(self.request, - key='products.suggested_price_history', - data=data, + grid = grids.Grid('products.suggested_price_history', data, + request=self.request, columns=[ 'price', 'since', @@ -1246,9 +1243,8 @@ class ProductView(MasterView): # cost history data = [] # defer fetching until user asks for it - grid = grids.Grid(self.request, - key='products.cost_history', - data=data, + grid = grids.Grid('products.cost_history', data, + request=self.request, columns=[ 'cost', 'vendor', @@ -1339,8 +1335,7 @@ class ProductView(MasterView): factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.vendor_sources', + key='{}.vendor_sources'.format(route_prefix), data=[], columns=columns, labels={ @@ -1381,8 +1376,7 @@ class ProductView(MasterView): factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.lookup_codes', + key='{}.lookup_codes'.format(route_prefix), data=[], columns=[ 'sequence', @@ -1857,8 +1851,7 @@ class ProductView(MasterView): lookup_fields.append('alt_code') if lookup_fields: product = self.products_handler.locate_product_for_entry( - session, term, lookup_fields=lookup_fields, - first_if_multiple=True) + session, term, lookup_fields=lookup_fields) if product: final_results.append(self.search_normalize_result(product)) @@ -2669,78 +2662,6 @@ class PendingProductView(MasterView): 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): base = globals() @@ -2750,9 +2671,6 @@ def defaults(config, **kwargs): PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) PendingProductView.defaults(config) - ProductCostView = kwargs.get('ProductCostView', base['ProductCostView']) - ProductCostView.defaults(config) - def includeme(config): defaults(config) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 5e00704e..1d11130c 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -24,8 +24,6 @@ Base class for purchasing batch views """ -import warnings - from rattail.db.model import PurchaseBatch, PurchaseBatchRow import colander @@ -69,8 +67,6 @@ class PurchasingBatchView(BatchMasterView): 'store', 'buyer', 'vendor', - 'description', - 'workflow', 'department', 'purchase', 'vendor_email', @@ -162,174 +158,6 @@ class PurchasingBatchView(BatchMasterView): def batch_mode(self): 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): model = self.model return session.query(model.PurchaseBatch)\ @@ -398,40 +226,20 @@ class PurchasingBatchView(BatchMasterView): def configure_form(self, f): super().configure_form(f) - model = self.app.model - enum = self.app.enum - route_prefix = self.get_route_prefix() - - today = self.app.today() + model = self.model batch = f.model_instance - workflow = self.request.matchdict.get('workflow_key') - vendor_handler = self.app.get_vendor_handler() + app = self.get_rattail_app() + today = app.localtime().date() # 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') + f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) # store - single_store = self.config.single_store() + single_store = self.rattail_config.single_store() if self.creating: f.replace('store', 'store_uuid') if single_store: - store = self.config.get_store(self.Session()) + store = self.rattail_config.get_store(self.Session()) f.set_widget('store_uuid', dfwidget.HiddenWidget()) f.set_default('store_uuid', store.uuid) f.set_hidden('store_uuid') @@ -455,6 +263,7 @@ class PurchasingBatchView(BatchMasterView): if self.creating: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") + vendor_handler = app.get_vendor_handler() use_dropdown = vendor_handler.choice_uses_dropdown() if use_dropdown: vendors = self.Session.query(model.Vendor)\ @@ -504,7 +313,7 @@ class PurchasingBatchView(BatchMasterView): if buyer: buyer_display = str(buyer) elif self.creating: - buyer = self.app.get_employee(self.request.user) + buyer = app.get_employee(self.request.user) if buyer: buyer_display = str(buyer) f.set_default('buyer_uuid', buyer.uuid) @@ -515,30 +324,6 @@ class PurchasingBatchView(BatchMasterView): field_display=buyer_display, service_url=buyers_url)) 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 if self.creating: f.set_type('invoice_file', 'file', required=False) @@ -556,7 +341,7 @@ class PurchasingBatchView(BatchMasterView): if vendor: kwargs['vendor'] = vendor - parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs) + parsers = self.handler.get_supported_invoice_parsers(**kwargs) parser_values = [(p.key, p.display) for p in parsers] if len(parsers) == 1: f.set_default('invoice_parser_key', parsers[0].key) @@ -615,35 +400,6 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', '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): store = batch.store if not store: @@ -759,12 +515,10 @@ class PurchasingBatchView(BatchMasterView): def get_batch_kwargs(self, batch, **kwargs): kwargs = super().get_batch_kwargs(batch, **kwargs) - model = self.app.model + model = self.model kwargs['mode'] = self.batch_mode - kwargs['workflow'] = self.request.POST['workflow'] kwargs['truck_dump'] = batch.truck_dump - kwargs['order_parser_key'] = batch.order_parser_key kwargs['invoice_parser_key'] = batch.invoice_parser_key if batch.store: @@ -782,11 +536,6 @@ class PurchasingBatchView(BatchMasterView): elif 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: kwargs['department'] = batch.department elif batch.department_uuid: @@ -1044,8 +793,8 @@ class PurchasingBatchView(BatchMasterView): factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.row_credits', + key='{}.row_credits'.format(route_prefix), + request=self.request, data=[], columns=[ 'credit_type', @@ -1170,25 +919,6 @@ class PurchasingBatchView(BatchMasterView): # # otherwise just view batch again # 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): diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index c7cc7bfc..2e24eebb 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -28,10 +28,14 @@ import os import json import openpyxl +from sqlalchemy import orm +from rattail.db import model, api 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 @@ -47,8 +51,6 @@ class OrderingBatchView(PurchasingBatchView): rows_editable = True has_worksheet = True default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html' - downloadable = True - configurable = True labels = { 'po_total_calculated': "PO Total", @@ -57,14 +59,9 @@ class OrderingBatchView(PurchasingBatchView): form_fields = [ 'id', 'store', - 'vendor', - 'description', - 'workflow', - 'order_file', - 'order_parser_key', 'buyer', + 'vendor', 'department', - 'params', 'purchase', 'vendor_email', 'vendor_fax', @@ -135,26 +132,15 @@ class OrderingBatchView(PurchasingBatchView): return self.enum.PURCHASE_BATCH_MODE_ORDERING def configure_form(self, f): - super().configure_form(f) + super(OrderingBatchView, self).configure_form(f) batch = f.model_instance - workflow = self.request.matchdict.get('workflow_key') # purchase if self.creating or not batch.executed or not batch.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): - kwargs = super().get_batch_kwargs(batch, **kwargs) + kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs) kwargs['ship_method'] = batch.ship_method kwargs['notes_to_vendor'] = batch.notes_to_vendor return kwargs @@ -169,7 +155,7 @@ class OrderingBatchView(PurchasingBatchView): * ``cases_ordered`` * ``units_ordered`` """ - super().configure_row_form(f) + super(OrderingBatchView, self).configure_row_form(f) # when editing, only certain fields should allow changes if self.editing: @@ -322,7 +308,7 @@ class OrderingBatchView(PurchasingBatchView): title = self.get_instance_title(batch) order_date = batch.date_ordered if not order_date: - order_date = self.app.today() + order_date = localtime(self.rattail_config).date() return self.render_to_response('worksheet', { 'batch': batch, @@ -383,7 +369,6 @@ class OrderingBatchView(PurchasingBatchView): of being updated. If a matching row is not found, it will not be created. """ - model = self.app.model batch = self.get_instance() try: @@ -493,75 +478,13 @@ class OrderingBatchView(PurchasingBatchView): return self.file_response(path) def get_execute_success_url(self, batch, result, **kwargs): - model = self.app.model if isinstance(result, model.Purchase): return self.request.route_url('purchases.view', uuid=result.uuid) - 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) + return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs) @classmethod def defaults(cls, config): cls._ordering_defaults(config) - cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 01858c98..be15c1a8 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -25,22 +25,22 @@ Views for 'receiving' (purchasing) batches """ import os +import re import decimal import logging from collections import OrderedDict -# import humanize +import humanize from rattail import pod -from rattail.util import simple_error +from rattail.util import prettify, simple_error import colander from deform import widget as dfwidget from webhelpers2.html import tags, HTML -from wuttaweb.util import get_form_data - -from tailbone import forms +from tailbone import forms, grids +from tailbone.util import get_form_data from tailbone.views.purchasing import PurchasingBatchView @@ -108,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView): 'store', 'vendor', 'description', - 'workflow', + 'receiving_workflow', 'truck_dump', 'truck_dump_children_first', 'truck_dump_children', @@ -235,18 +235,135 @@ class ReceivingBatchView(PurchasingBatchView): if not self.handler.allow_truck_dump_receiving(): g.remove('truck_dump') - def get_supported_vendors(self): - """ """ - vendor_handler = self.app.get_vendor_handler() - 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) - return vendors + def create(self, form=None, **kwargs): + """ + Custom view for creating a new receiving batch. We split the process + into two steps, 1) choose and 2) create. 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. + + See also + :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()` + which uses similar logic. + """ + 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): @@ -287,7 +404,13 @@ class ReceivingBatchView(PurchasingBatchView): # cancel should take us back to choosing a workflow f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) - # TODO: remove this + # receiving_workflow + 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 if self.creating: f.set_widget('batch_type', dfwidget.HiddenWidget()) @@ -402,7 +525,7 @@ class ReceivingBatchView(PurchasingBatchView): # multiple invoice files (if applicable) if (not self.creating - and batch.get_param('workflow') == 'from_multi_invoice'): + and batch.get_param('receiving_workflow') == 'from_multi_invoice'): if 'invoice_files' not in f: f.insert_before('invoice_file', 'invoice_files') @@ -501,6 +624,12 @@ class ReceivingBatchView(PurchasingBatchView): items.append(HTML.tag('li', c=[link])) 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): params = super().get_visible_params(batch) @@ -525,40 +654,42 @@ class ReceivingBatchView(PurchasingBatchView): def get_batch_kwargs(self, 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 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'] - workflow = kwargs['workflow'] - if workflow == 'from_scratch': + # TODO: ugh should just have workflow and no batch_type + kwargs['receiving_workflow'] = batch_type + if batch_type == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) - elif workflow == 'from_invoice': + elif batch_type == 'from_invoice': pass - elif workflow == 'from_multi_invoice': + elif batch_type == 'from_multi_invoice': pass - elif workflow == 'from_po': + elif batch_type == 'from_po': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif workflow == 'from_po_with_invoice': + elif batch_type == 'from_po_with_invoice': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif workflow == 'truck_dump_children_first': + elif batch_type == 'truck_dump_children_first': kwargs['truck_dump'] = True kwargs['truck_dump_children_first'] = True kwargs['order_quantities_known'] = True # TODO: this makes sense in some cases, but all? # (should just omit that field when not relevant) kwargs['date_ordered'] = None - elif workflow == 'truck_dump_children_last': + elif batch_type == 'truck_dump_children_last': kwargs['truck_dump'] = True kwargs['truck_dump_ready'] = True # TODO: this makes sense in some cases, but all? # (should just omit that field when not relevant) kwargs['date_ordered'] = None - elif workflow.startswith('truck_dump_child'): + elif batch_type.startswith('truck_dump_child'): truck_dump = self.get_instance() kwargs['store'] = truck_dump.store kwargs['vendor'] = truck_dump.vendor @@ -643,10 +774,8 @@ class ReceivingBatchView(PurchasingBatchView): breakdown = self.make_po_vs_invoice_breakdown(batch) factory = self.get_grid_factory() - g = factory(self.request, - key='batch_po_vs_invoice_breakdown', - data=[], - columns=['title', 'count']) + g = factory('batch_po_vs_invoice_breakdown', [], + columns=['title', 'count']) g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)") kwargs['po_vs_invoice_breakdown_data'] = breakdown kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal( @@ -902,16 +1031,14 @@ class ReceivingBatchView(PurchasingBatchView): if batch.is_truck_dump_parent(): permission_prefix = self.get_permission_prefix() if self.request.has_perm('{}.edit_row'.format(permission_prefix)): - transform = self.make_action('transform', + transform = grids.GridAction('transform', icon='shuffle', label="Transform to Unit", url=self.transform_unit_url) - if g.actions and g.actions[-1].key == 'delete': - delete = g.actions.pop() - g.actions.append(transform) - g.actions.append(delete) - else: - g.actions.append(transform) + g.more_actions.append(transform) + if g.main_actions and g.main_actions[-1].key == 'delete': + delete = g.main_actions.pop() + g.more_actions.append(delete) # truck_dump_status if not batch.is_truck_dump_parent(): @@ -984,7 +1111,7 @@ class ReceivingBatchView(PurchasingBatchView): and self.row_editable(row)): # add the Un-Declare action - g.actions.append(self.make_action( + g.main_actions.append(self.make_action( 'remove', label="Un-Declare", url='#', icon='trash', link_class='has-text-danger', @@ -1855,12 +1982,6 @@ class ReceivingBatchView(PurchasingBatchView): 'type': bool}, # 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', 'option': 'purchase.supported_vendors_only', 'type': bool}, @@ -1911,7 +2032,6 @@ class ReceivingBatchView(PurchasingBatchView): @classmethod def defaults(cls, config): cls._receiving_defaults(config) - cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) @@ -1919,11 +2039,17 @@ class ReceivingBatchView(PurchasingBatchView): def _receiving_defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() 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 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), @@ -1976,6 +2102,33 @@ class ReceivingBatchView(PurchasingBatchView): 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): mode = colander.SchemaNode(colander.String(), diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index 099224be..aedda61c 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -308,8 +308,7 @@ class ReportOutputView(ExportMasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.params', + key='{}.params'.format(route_prefix), data=params, columns=['key', 'value'], labels={'key': "Name"}, @@ -706,12 +705,9 @@ class ProblemReportView(MasterView): return ', '.join(recips) def render_days(self, report_info, field): - factory = self.get_grid_factory() - g = factory(self.request, - key='days', - data=[], - columns=['weekday_name', 'enabled'], - labels={'weekday_name': "Weekday"}) + g = self.get_grid_factory()('days', [], + columns=['weekday_name', 'enabled'], + labels={'weekday_name': "Weekday"}) return HTML.literal(g.render_table_element(data_prop='weekdaysData')) def template_kwargs_view(self, **kwargs): diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index e8a6d8a2..0316ea87 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -30,6 +30,7 @@ from sqlalchemy import orm from openpyxl.styles import Font, PatternFill from rattail.db.model import Role +from rattail.db.auth import administrator_role, guest_role, authenticated_role from rattail.excel import ExcelWriter import colander @@ -106,11 +107,8 @@ class RoleView(PrincipalMasterView): if role.node_type and role.node_type != self.rattail_config.node_type(): return False - app = self.get_rattail_app() - auth = app.get_auth_handler() - # only "root" can edit Administrator - if role is auth.get_role_administrator(self.Session()): + if role is administrator_role(self.Session()): return self.request.is_root # only "admin" can edit "admin-ish" roles @@ -118,11 +116,11 @@ class RoleView(PrincipalMasterView): return self.request.is_admin # can edit Authenticated only if user has permission - if role is auth.get_role_authenticated(self.Session()): + if role is authenticated_role(self.Session()): return self.has_perm('edit_authenticated') # can edit Guest only if user has permission - if role is auth.get_role_anonymous(self.Session()): + if role is guest_role(self.Session()): return self.has_perm('edit_guest') # current user can edit their own roles, only if they have permission @@ -141,14 +139,11 @@ class RoleView(PrincipalMasterView): if role.node_type and role.node_type != self.rattail_config.node_type(): return False - app = self.get_rattail_app() - auth = app.get_auth_handler() - - if role is auth.get_role_administrator(self.Session()): + if role is administrator_role(self.Session()): return False - if role is auth.get_role_authenticated(self.Session()): + if role is authenticated_role(self.Session()): return False - if role is auth.get_role_anonymous(self.Session()): + if role is guest_role(self.Session()): return False # only "admin" can delete "admin-ish" roles @@ -191,17 +186,17 @@ class RoleView(PrincipalMasterView): # session_timeout f.set_renderer('session_timeout', self.render_session_timeout) - if self.editing and role is auth.get_role_anonymous(self.Session()): + if self.editing and role is guest_role(self.Session()): f.set_readonly('session_timeout') # sync_me, node_type if not self.creating: include = True - if role is auth.get_role_administrator(self.Session()): + if role is administrator_role(self.Session()): include = False - elif role is auth.get_role_authenticated(self.Session()): + elif role is authenticated_role(self.Session()): include = False - elif role is auth.get_role_anonymous(self.Session()): + elif role is guest_role(self.Session()): include = False if not include: f.remove('sync_me', 'sync_users', 'node_type') @@ -232,7 +227,7 @@ class RoleView(PrincipalMasterView): for groupkey in self.tailbone_permissions: for key in self.tailbone_permissions[groupkey]['perms']: if auth.has_permission(self.Session(), role, key, - include_anonymous=False, + include_guest=False, include_authenticated=False): granted.append(key) f.set_default('permissions', granted) @@ -240,14 +235,12 @@ class RoleView(PrincipalMasterView): f.remove_field('permissions') def render_users(self, role, field): - app = self.get_rattail_app() - auth = app.get_auth_handler() - if role is auth.get_role_anonymous(self.Session()): + if role is guest_role(self.Session()): return ("The guest role is implied for all anonymous users, " "i.e. when not logged in.") - if role is auth.get_role_authenticated(self.Session()): + if role is authenticated_role(self.Session()): return ("The authenticated role is implied for all users, " "but only when logged in.") @@ -255,8 +248,8 @@ class RoleView(PrincipalMasterView): permission_prefix = self.get_permission_prefix() factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.users', + key='{}.users'.format(route_prefix), + request=self.request, data=[], columns=[ 'full_name', @@ -269,9 +262,9 @@ class RoleView(PrincipalMasterView): ) if self.request.has_perm('users.view'): - g.actions.append(self.make_action('view', icon='eye')) + g.main_actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('users.edit'): - g.actions.append(self.make_action('edit', icon='edit')) + g.main_actions.append(self.make_action('edit', icon='edit')) return HTML.literal( g.render_table_element(data_prop='usersData')) @@ -287,8 +280,8 @@ class RoleView(PrincipalMasterView): if the current user is an admin; otherwise it will be the "subset" of permissions which the current user has been granted. """ - # get all known permissions from settings cache - permissions = self.request.registry.settings.get('wutta_permissions', {}) + # fetch full set of permissions registered in the app + permissions = self.request.registry.settings.get('tailbone_permissions', {}) # admin user gets to manage all permissions if self.request.is_admin: @@ -315,9 +308,7 @@ class RoleView(PrincipalMasterView): return available def render_session_timeout(self, role, field): - app = self.get_rattail_app() - auth = app.get_auth_handler() - if role is auth.get_role_anonymous(self.Session()): + if role is guest_role(self.Session()): return "(not applicable)" if role.session_timeout is None: return "" @@ -356,26 +347,23 @@ class RoleView(PrincipalMasterView): auth.revoke_permission(role, pkey) def template_kwargs_view(self, **kwargs): - app = self.get_rattail_app() - auth = app.get_auth_handler() model = self.model role = kwargs['instance'] if role.users: users = sorted(role.users, key=lambda u: u.username) actions = [ - self.make_action('view', icon='zoomin', + grids.GridAction('view', icon='zoomin', url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid)) ] - kwargs['users'] = grids.Grid(self.request, - data=users, - columns=['username', 'active'], + kwargs['users'] = grids.Grid(None, users, ['username', 'active'], + request=self.request, model_class=model.User, - actions=actions) + main_actions=actions) else: kwargs['users'] = None - kwargs['guest_role'] = auth.get_role_anonymous(self.Session()) - kwargs['authenticated_role'] = auth.get_role_authenticated(self.Session()) + kwargs['guest_role'] = guest_role(self.Session()) + kwargs['authenticated_role'] = authenticated_role(self.Session()) role = kwargs['instance'] if role not in (kwargs['guest_role'], kwargs['authenticated_role']): @@ -396,11 +384,9 @@ class RoleView(PrincipalMasterView): return kwargs def before_delete(self, role): - app = self.get_rattail_app() - auth = app.get_auth_handler() - admin = auth.get_role_administrator(self.Session()) - guest = auth.get_role_anonymous(self.Session()) - authenticated = auth.get_role_authenticated(self.Session()) + admin = administrator_role(self.Session()) + guest = guest_role(self.Session()) + authenticated = authenticated_role(self.Session()) if role in (admin, guest, authenticated): self.request.session.flash("You may not delete the {} role.".format(role.name), 'error') return self.redirect(self.request.get_referrer(default=self.request.route_url('roles'))) @@ -416,7 +402,7 @@ class RoleView(PrincipalMasterView): .options(orm.joinedload(model.Role._permissions)) roles = [] for role in all_roles: - if auth.has_permission(session, role, permission, include_anonymous=False): + if auth.has_permission(session, role, permission, include_guest=False): roles.append(role) return roles @@ -489,7 +475,7 @@ class RoleView(PrincipalMasterView): # and show an 'X' for any role which has this perm for col, role in enumerate(roles, 2): if auth.has_permission(self.Session(), role, key, - include_anonymous=False): + include_guest=False): sheet.cell(row=writing_row, column=col, value="X") writing_row += 1 diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 10a0c2eb..8d389530 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -24,167 +24,214 @@ Settings Views """ -import json +import os import re +import subprocess +import sys +from collections import OrderedDict -import colander +import json from rattail.db.model import Setting from rattail.settings import Setting as AppSetting from rattail.util import import_module_path -from tailbone import forms, grids +import colander + +from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView, View -from wuttaweb.util import get_libver, get_liburl -from wuttaweb.views.settings import AppInfoView as WuttaAppInfoView +from tailbone.util import get_libver, get_liburl -class AppInfoView(WuttaAppInfoView): - """ """ - Session = Session - weblib_config_prefix = 'tailbone' +class AppInfoView(MasterView): + """ + Master view for the overall app, to show/edit config etc. + """ + 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 - # TODO: for now we override to get tailbone searchable grid - def make_grid(self, **kwargs): - """ """ - return grids.Grid(self.request, **kwargs) - - def configure_grid(self, g): - """ """ - super().configure_grid(g) - - # name - g.set_searchable('name') - - # editable_project_location - g.set_searchable('editable_project_location') - - def configure_get_context(self, **kwargs): - """ """ - context = super().configure_get_context(**kwargs) - simple_settings = context['simple_settings'] - weblibs = context['weblibs'] - - for weblib in weblibs: - key = weblib['key'] - - # TODO: this is only needed to migrate legacy settings to - # use the newer wuttaweb setting names - url = simple_settings[f'wuttaweb.liburl.{key}'] - if not url and weblib['configured_url']: - simple_settings[f'wuttaweb.liburl.{key}'] = weblib['configured_url'] - - 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', + grid_columns = [ + 'name', + 'version', + 'editable_project_location', ] - def configure_get_simple_settings(self): - """ """ - simple_settings = super().configure_get_simple_settings() + def get_index_title(self): + app = self.get_rattail_app() + return "{} for {}".format(self.get_model_title_plural(), + app.get_title()) - # TODO: - # there are several email config keys which differ between - # wuttjamaican and rattail. basically all of the "profile" keys - # have a different prefix. + 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()) - # after wuttaweb has declared its settings, we examine each and - # overwrite the value if one is defined with rattail config key. - # (nb. this happens even if wuttjamaican key has a value!) + for pkg in data: + pkg.setdefault('editable_project_location', '') - # note that we *do* declare the profile mismatch keys for - # rattail, as part of simple settings. this ensures the - # parent logic will always remove them when saving. however - # we must also include them in gather_settings() to ensure - # they are saved to match wuttjamaican values. + return data - # there are also a couple of flags where rattail's default is the - # opposite of wuttjamaican. so we overwrite those too as needed. + def configure_grid(self, g): + super().configure_grid(g) - for setting in simple_settings: + g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) + g.set_sort_defaults('name') + g.set_searchable('name') - # nb. the update home page redirect setting is off by - # 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 + g.sorters['version'] = g.make_simple_sorter('version', foldcase=True) - # nb. sending email is off by default for wuttjamaican, - # 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 + g.sorters['editable_project_location'] = g.make_simple_sorter( + 'editable_project_location', foldcase=True) + g.set_searchable('editable_project_location') - # nb. this one is even more special, key is entirely different - elif setting['name'] == 'rattail.email.default.sender': - value = self.config.get('rattail.email.default.sender') - if value is None: - value = self.config.get('rattail.mail.default.from') - setting['value'] = value + def template_kwargs_index(self, **kwargs): + kwargs = super().template_kwargs_index(**kwargs) + kwargs['configure_button_title'] = "Configure App" + return kwargs - else: + def configure_get_context(self, **kwargs): + context = super().configure_get_context(**kwargs) - # nb. fetch alternate value for profile key mismatch - for key in self.configure_profile_key_mismatches: - if setting['name'] == f'rattail.email.{key}': - value = self.config.get(f'rattail.email.{key}') - if value is None: - value = self.config.get(f'rattail.mail.{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'}, + weblibs = 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"), ]) - 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 weblibs: + title = weblibs[key] + weblibs[key] = { + 'key': key, + 'title': title, - for key in self.get_weblibs(): - simple_settings.extend([ - {'name': f'tailbone.libver.{key}'}, - {'name': f'tailbone.liburl.{key}'}, - ]) + # nb. these values are exactly as configured, and are + # used for editing the settings + 'configured_version': get_libver(self.request, key, fallback=False), + 'configured_url': get_liburl(self.request, key, fallback=False), - return simple_settings + # these are for informational purposes only + 'default_version': get_libver(self.request, key, default_only=True), + 'live_url': get_liburl(self.request, key), + } - def configure_gather_settings(self, data, simple_settings=None): - """ """ - settings = super().configure_gather_settings(data, simple_settings=simple_settings) + context['weblibs'] = list(weblibs.values()) + return context - # nb. must add legacy rattail profile settings to match new ones - for setting in list(settings): + def configure_get_simple_settings(self): + return [ - if setting['name'] == 'rattail.email.default.sender': - value = setting['value'] - settings.append({'name': 'rattail.mail.default.from', - 'value': value}) + # basics + {'section': 'rattail', + 'option': 'app_title'}, + {'section': 'rattail', + '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'}, - 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 + # display + {'section': 'tailbone', + 'option': 'background_color'}, - return settings + # grids + {'section': 'tailbone', + 'option': 'grid.default_pagesize', + # TODO: seems like should enforce this, but validation is + # not setup yet + # 'type': int + }, + + # web libs + {'section': 'tailbone', + 'option': 'libver.vue'}, + {'section': 'tailbone', + 'option': 'liburl.vue'}, + {'section': 'tailbone', + 'option': 'libver.vue_resource'}, + {'section': 'tailbone', + 'option': 'liburl.vue_resource'}, + {'section': 'tailbone', + 'option': 'libver.buefy'}, + {'section': 'tailbone', + 'option': 'liburl.buefy'}, + {'section': 'tailbone', + 'option': 'libver.buefy.css'}, + {'section': 'tailbone', + 'option': 'liburl.buefy.css'}, + {'section': 'tailbone', + 'option': 'libver.fontawesome'}, + {'section': 'tailbone', + 'option': 'liburl.fontawesome'}, + + {'section': 'tailbone', + 'option': 'libver.bb_vue'}, + {'section': 'tailbone', + 'option': 'liburl.bb_vue'}, + + {'section': 'tailbone', + 'option': 'libver.bb_oruga'}, + {'section': 'tailbone', + 'option': 'liburl.bb_oruga'}, + + {'section': 'tailbone', + 'option': 'libver.bb_oruga_bulma'}, + {'section': 'tailbone', + 'option': 'liburl.bb_oruga_bulma'}, + + {'section': 'tailbone', + 'option': 'libver.bb_oruga_bulma_css'}, + {'section': 'tailbone', + 'option': 'liburl.bb_oruga_bulma_css'}, + + {'section': 'tailbone', + 'option': 'libver.bb_fontawesome_svg_core'}, + {'section': 'tailbone', + 'option': 'liburl.bb_fontawesome_svg_core'}, + + {'section': 'tailbone', + 'option': 'libver.bb_free_solid_svg_icons'}, + {'section': 'tailbone', + 'option': 'liburl.bb_free_solid_svg_icons'}, + + {'section': 'tailbone', + 'option': 'libver.bb_vue_fontawesome'}, + {'section': 'tailbone', + 'option': 'liburl.bb_vue_fontawesome'}, + + # nb. these are no longer used (deprecated), but we keep + # them defined here so the tool auto-deletes them + {'section': 'tailbone', + 'option': 'buefy_version'}, + {'section': 'tailbone', + 'option': 'vue_version'}, + + ] class SettingView(MasterView): diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index 7540abbe..d551d6e6 100644 --- a/tailbone/views/tempmon/core.py +++ b/tailbone/views/tempmon/core.py @@ -77,8 +77,8 @@ class MasterView(views.MasterView): factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.probes', + key='{}.probes'.format(route_prefix), + request=self.request, data=[], columns=[ 'description', @@ -96,7 +96,7 @@ class MasterView(views.MasterView): 'critical_temp_max': "Crit. Max", }, linked_columns=['description'], - actions=actions, + main_actions=actions, ) return HTML.literal( g.render_table_element(data_prop='probesData')) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index d5f077aa..9c150c6a 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -246,10 +246,10 @@ class TransactionView(MasterView): factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.custorder_xref_markers', + key='{}.custorder_xref_markers'.format(route_prefix), data=[], - columns=['custorder_xref', 'custorder_item_xref']) + columns=['custorder_xref', 'custorder_item_xref'], + request=self.request) return HTML.literal( g.render_table_element(data_prop='custorderXrefMarkersData')) @@ -355,11 +355,11 @@ class TransactionView(MasterView): factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.discounts', + key='{}.discounts'.format(route_prefix), data=[], columns=['discount_type', 'description', 'amount'], - labels={'discount_type': "Type"}) + labels={'discount_type': "Type"}, + request=self.request) return HTML.literal( g.render_table_element(data_prop='discountsData')) diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index ffa88032..3276b64d 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -348,27 +348,56 @@ class UpgradeView(MasterView): commit_hash_pattern = re.compile(r'^.{40}$') def get_changelog_projects(self): - project_map = { - 'onager': 'onager', - 'pyCOREPOS': 'pycorepos', - 'rattail': 'rattail', - 'rattail_corepos': 'rattail-corepos', - 'rattail-onager': 'rattail-onager', - 'rattail_tempmon': 'rattail-tempmon', - 'rattail_woocommerce': 'rattail-woocommerce', - 'Tailbone': 'tailbone', - 'tailbone_corepos': 'tailbone-corepos', - 'tailbone-onager': 'tailbone-onager', - 'tailbone_theo': 'theo', - 'tailbone_woocommerce': 'tailbone-woocommerce', + projects = { + 'rattail': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst', + }, + 'Tailbone': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst', + }, + 'pyCOREPOS': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst', + }, + '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 def get_changelog_url(self, project, old_version, new_version): diff --git a/tailbone/views/users.py b/tailbone/views/users.py index dfed0a11..b641e578 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -44,6 +44,9 @@ class UserView(PrincipalMasterView): Master view for the User model. """ model_class = User + has_rows = True + rows_title = "User Events" + model_row_class = UserEvent has_versions = True touchable = True mergeable = True @@ -74,11 +77,6 @@ class UserView(PrincipalMasterView): 'permissions', ] - has_rows = True - model_row_class = UserEvent - rows_title = "User Events" - rows_viewable = False - row_grid_columns = [ 'type_code', 'occurred', @@ -210,13 +208,9 @@ class UserView(PrincipalMasterView): person_display = str(person) elif self.editing: person_display = str(user.person or '') - try: - people_url = self.request.route_url('people.autocomplete') - except KeyError: - pass # TODO: wutta compat - else: - f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=person_display, service_url=people_url)) + people_url = self.request.route_url('people.autocomplete') + f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( + field_display=person_display, service_url=people_url)) f.set_validator('person_uuid', self.valid_person) f.set_label('person_uuid', "Person") @@ -282,10 +276,10 @@ class UserView(PrincipalMasterView): # fs.confirm_password.attrs(autocomplete='new-password') if self.viewing: - permissions = self.request.registry.settings.get('wutta_permissions', {}) + permissions = self.request.registry.settings.get('tailbone_permissions', {}) f.set_renderer('permissions', PermissionsRenderer(request=self.request, permissions=permissions, - include_anonymous=True, + include_guest=True, include_authenticated=True)) else: f.remove('permissions') @@ -299,11 +293,11 @@ class UserView(PrincipalMasterView): factory = self.get_grid_factory() g = factory( - self.request, - key=f'{route_prefix}.api_tokens', + request=self.request, + key='{}.api_tokens'.format(route_prefix), data=[], columns=['description', 'created'], - actions=[ + main_actions=[ self.make_action('delete', icon='trash', click_handler="$emit('api-token-delete', props.row)")]) @@ -516,6 +510,7 @@ class UserView(PrincipalMasterView): g.set_sort_defaults('occurred', 'desc') g.set_enum('type_code', self.enum.USER_EVENT) g.set_label('type_code', "Event Type") + g.main_actions = [] def get_version_child_classes(self): model = self.model @@ -801,8 +796,4 @@ def defaults(config, **kwargs): def includeme(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) + defaults(config) diff --git a/tailbone/views/workorders.py b/tailbone/views/workorders.py index d8094e4b..a53037bc 100644 --- a/tailbone/views/workorders.py +++ b/tailbone/views/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -83,12 +83,12 @@ class WorkOrderView(MasterView): ] def __init__(self, request): - super().__init__(request) + super(WorkOrderView, self).__init__(request) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def configure_grid(self, g): - super().configure_grid(g) + super(WorkOrderView, self).configure_grid(g) model = self.model # customer @@ -113,7 +113,7 @@ class WorkOrderView(MasterView): return 'warning' def configure_form(self, f): - super().configure_form(f) + super(WorkOrderView, self).configure_form(f) model = self.model SelectWidget = forms.widgets.JQuerySelectWidget @@ -208,7 +208,7 @@ class WorkOrderView(MasterView): return event.workorder def configure_row_grid(self, g): - super().configure_row_grid(g) + super(WorkOrderView, self).configure_row_grid(g) g.set_enum('type_code', self.enum.WORKORDER_EVENT) g.set_sort_defaults('occurred') @@ -353,7 +353,7 @@ class WorkOrderView(MasterView): class StatusFilter(grids.filters.AlchemyIntegerFilter): def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super(StatusFilter, self).__init__(*args, **kwargs) from drild import enum @@ -369,14 +369,14 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter): @property def verb_labels(self): - labels = dict(super().verb_labels) + labels = dict(super(StatusFilter, self).verb_labels) labels['is_active'] = "Is Active" labels['not_active'] = "Is Not Active" return labels @property def valueless_verbs(self): - verbs = list(super().valueless_verbs) + verbs = list(super(StatusFilter, self).valueless_verbs) verbs.extend([ 'is_active', 'not_active', @@ -385,11 +385,7 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter): @property def default_verbs(self): - verbs = super().default_verbs - if callable(verbs): - verbs = verbs() - - verbs = list(verbs or []) + verbs = list(super(StatusFilter, self).default_verbs) verbs.insert(0, 'is_active') verbs.insert(1, 'not_active') return verbs diff --git a/tailbone/views/wutta/__init__.py b/tailbone/views/wutta/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py deleted file mode 100644 index bd96bd4d..00000000 --- a/tailbone/views/wutta/people.py +++ /dev/null @@ -1,143 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Person Views -""" - -import colander -import sqlalchemy as sa -from webhelpers2.html import HTML - -from wuttaweb.views import people as wutta -from tailbone.views import people as tailbone -from tailbone.db import Session -from rattail.db.model import Person -from tailbone.grids import Grid - - -class PersonView(wutta.PersonView): - """ - 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 = Person - Session = Session - - labels = { - 'display_name': "Full Name", - } - - grid_columns = [ - 'display_name', - 'first_name', - 'last_name', - 'phone', - 'email', - 'merge_requested', - ] - - filter_defaults = { - 'display_name': {'active': True, 'verb': 'contains'}, - } - sort_defaults = 'display_name' - - form_fields = [ - 'first_name', - 'middle_name', - 'last_name', - 'display_name', - 'phone', - 'email', - # TODO - # 'address', - ] - - ############################## - # 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): - """ """ - super().configure_grid(g) - - # display_name - g.set_link('display_name') - - # merge_requested - g.set_label('merge_requested', "MR") - g.set_renderer('merge_requested', self.render_merge_requested) - - def configure_form(self, f): - """ """ - super().configure_form(f) - - # email - if self.creating or self.editing: - f.remove('email') - else: - # nb. avoid colanderalchemy - f.set_node('email', colander.String()) - - # phone - if self.creating or self.editing: - f.remove('phone') - else: - # nb. avoid colanderalchemy - f.set_node('phone', colander.String()) - - ############################## - # support methods - ############################## - - def render_merge_requested(self, person, key, value, session=None): - """ """ - model = self.app.model - session = session or self.Session() - merge_request = session.query(model.MergePeopleRequest)\ - .filter(sa.or_( - model.MergePeopleRequest.removing_uuid == person.uuid, - model.MergePeopleRequest.keeping_uuid == person.uuid))\ - .filter(model.MergePeopleRequest.merged == None)\ - .first() - if merge_request: - return HTML.tag('span', - class_='has-text-danger has-text-weight-bold', - title="A merge has been requested for this person.", - c="MR") - - -def defaults(config, **kwargs): - kwargs.setdefault('PersonView', PersonView) - tailbone.defaults(config, **kwargs) - - -def includeme(config): - defaults(config) diff --git a/tailbone/views/wutta/users.py b/tailbone/views/wutta/users.py deleted file mode 100644 index 3c3f8d52..00000000 --- a/tailbone/views/wutta/users.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -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) diff --git a/tailbone/webapi.py b/tailbone/webapi.py index d0edb412..1c2fa106 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -85,34 +85,21 @@ def make_pyramid_config(settings): provider.configure_db_sessions(rattail_config, pyramid_config) # add some permissions magic - pyramid_config.add_directive('add_wutta_permission_group', - 'wuttaweb.auth.add_permission_group') - pyramid_config.add_directive('add_wutta_permission', - 'wuttaweb.auth.add_permission') - # TODO: deprecate / remove these - pyramid_config.add_directive('add_tailbone_permission_group', - 'wuttaweb.auth.add_permission_group') - pyramid_config.add_directive('add_tailbone_permission', - 'wuttaweb.auth.add_permission') + pyramid_config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') + pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') return pyramid_config -def main(global_config, views='tailbone.api', **settings): +def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) - # event hooks - pyramid_config.add_subscriber('tailbone.subscribers.new_request', - 'pyramid.events.NewRequest') - # TODO: is this really needed? - pyramid_config.add_subscriber('tailbone.subscribers.context_found', - 'pyramid.events.ContextFound') - - # views - pyramid_config.include(views) + # bring in some Tailbone + pyramid_config.include('tailbone.subscribers') + pyramid_config.include('tailbone.api') return pyramid_config.make_wsgi_app() diff --git a/tests/__init__.py b/tests/__init__.py index 40d8071f..7dec63f0 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -12,6 +12,9 @@ class TestCase(unittest.TestCase): def setUp(self): self.config = testing.setUp() + # TODO: this probably shouldn't (need to) be here + self.config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') + self.config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') def tearDown(self): testing.tearDown() diff --git a/tests/forms/__init__.py b/tests/forms/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/forms/test_core.py b/tests/forms/test_core.py deleted file mode 100644 index 894d2302..00000000 --- a/tests/forms/test_core.py +++ /dev/null @@ -1,153 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest.mock import patch - -import deform -from pyramid import testing - -from tailbone.forms import core as mod -from tests.util import WebTestCase - - -class TestForm(WebTestCase): - - def setUp(self): - self.setup_web() - self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') - - def make_form(self, **kwargs): - kwargs.setdefault('request', self.request) - return mod.Form(**kwargs) - - def test_basic(self): - form = self.make_form() - self.assertIsInstance(form, mod.Form) - - def test_vue_tagname(self): - - # default - form = self.make_form() - self.assertEqual(form.vue_tagname, 'tailbone-form') - - # can override with param - form = self.make_form(vue_tagname='something-else') - self.assertEqual(form.vue_tagname, 'something-else') - - # can still pass old param - form = self.make_form(component='legacy-name') - self.assertEqual(form.vue_tagname, 'legacy-name') - - def test_vue_component(self): - - # default - form = self.make_form() - self.assertEqual(form.vue_component, 'TailboneForm') - - # can override with param - form = self.make_form(vue_tagname='something-else') - self.assertEqual(form.vue_component, 'SomethingElse') - - # can still pass old param - form = self.make_form(component='legacy-name') - self.assertEqual(form.vue_component, 'LegacyName') - - def test_component(self): - - # default - form = self.make_form() - self.assertEqual(form.component, 'tailbone-form') - - # can override with param - form = self.make_form(vue_tagname='something-else') - self.assertEqual(form.component, 'something-else') - - # can still pass old param - form = self.make_form(component='legacy-name') - self.assertEqual(form.component, 'legacy-name') - - def test_component_studly(self): - - # default - form = self.make_form() - self.assertEqual(form.component_studly, 'TailboneForm') - - # can override with param - form = self.make_form(vue_tagname='something-else') - self.assertEqual(form.component_studly, 'SomethingElse') - - # can still pass old param - form = self.make_form(component='legacy-name') - self.assertEqual(form.component_studly, 'LegacyName') - - def test_button_label_submit(self): - form = self.make_form() - - # default - self.assertEqual(form.button_label_submit, "Submit") - - # can set submit_label - with patch.object(form, 'submit_label', new="Submit Label", create=True): - self.assertEqual(form.button_label_submit, "Submit Label") - - # can set save_label - with patch.object(form, 'save_label', new="Save Label"): - self.assertEqual(form.button_label_submit, "Save Label") - - # can set button_label_submit - form.button_label_submit = "New Label" - self.assertEqual(form.button_label_submit, "New Label") - - def test_get_deform(self): - model = self.app.model - - # sanity check - form = self.make_form(model_class=model.Setting) - dform = form.get_deform() - self.assertIsInstance(dform, deform.Form) - - def test_render_vue_tag(self): - model = self.app.model - - # sanity check - form = self.make_form(model_class=model.Setting) - html = form.render_vue_tag() - self.assertIn('<tailbone-form', html) - - def test_render_vue_template(self): - self.pyramid_config.include('tailbone.views.common') - model = self.app.model - - # sanity check - form = self.make_form(model_class=model.Setting) - html = form.render_vue_template(session=self.session) - self.assertIn('<form ', html) - - def test_get_vue_field_value(self): - model = self.app.model - form = self.make_form(model_class=model.Setting) - - # TODO: yikes what a hack (?) - dform = form.get_deform() - dform.set_appstruct({'name': 'foo', 'value': 'bar'}) - - # null for missing field - value = form.get_vue_field_value('doesnotexist') - self.assertIsNone(value) - - # normal value is returned - value = form.get_vue_field_value('name') - self.assertEqual(value, 'foo') - - # but not if we remove field from deform - # TODO: what is the use case here again? - dform.children.remove(dform['name']) - value = form.get_vue_field_value('name') - self.assertIsNone(value) - - def test_render_vue_field(self): - model = self.app.model - - # sanity check - form = self.make_form(model_class=model.Setting) - html = form.render_vue_field('name', session=self.session) - self.assertIn('<b-field ', html) diff --git a/tests/grids/__init__.py b/tests/grids/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py deleted file mode 100644 index 4d143c85..00000000 --- a/tests/grids/test_core.py +++ /dev/null @@ -1,579 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest.mock import MagicMock, patch - -from sqlalchemy import orm - -from tailbone.grids import core as mod -from tests.util import WebTestCase - - -class TestGrid(WebTestCase): - - def setUp(self): - self.setup_web() - self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') - - def make_grid(self, key=None, data=[], **kwargs): - return mod.Grid(self.request, key=key, data=data, **kwargs) - - def test_basic(self): - grid = self.make_grid('foo') - self.assertIsInstance(grid, mod.Grid) - - def test_deprecated_params(self): - - # component - grid = self.make_grid() - self.assertEqual(grid.vue_tagname, 'tailbone-grid') - grid = self.make_grid(component='blarg') - self.assertEqual(grid.vue_tagname, 'blarg') - - # default_sortkey, default_sortdir - grid = self.make_grid() - self.assertEqual(grid.sort_defaults, []) - grid = self.make_grid(default_sortkey='name') - self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')]) - grid = self.make_grid(default_sortdir='desc') - self.assertEqual(grid.sort_defaults, []) - grid = self.make_grid(default_sortkey='name', default_sortdir='desc') - self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) - - # pageable - grid = self.make_grid() - self.assertFalse(grid.paginated) - grid = self.make_grid(pageable=True) - self.assertTrue(grid.paginated) - - # default_pagesize - grid = self.make_grid() - self.assertEqual(grid.pagesize, 20) - grid = self.make_grid(default_pagesize=15) - self.assertEqual(grid.pagesize, 15) - - # default_page - grid = self.make_grid() - self.assertEqual(grid.page, 1) - grid = self.make_grid(default_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): - - # default - grid = self.make_grid('foo') - self.assertEqual(grid.vue_tagname, 'tailbone-grid') - - # can override with param - grid = self.make_grid('foo', vue_tagname='something-else') - self.assertEqual(grid.vue_tagname, 'something-else') - - # can still pass old param - grid = self.make_grid('foo', component='legacy-name') - self.assertEqual(grid.vue_tagname, 'legacy-name') - - def test_vue_component(self): - - # default - grid = self.make_grid('foo') - self.assertEqual(grid.vue_component, 'TailboneGrid') - - # can override with param - grid = self.make_grid('foo', vue_tagname='something-else') - self.assertEqual(grid.vue_component, 'SomethingElse') - - # can still pass old param - grid = self.make_grid('foo', component='legacy-name') - self.assertEqual(grid.vue_component, 'LegacyName') - - def test_component(self): - - # default - grid = self.make_grid('foo') - self.assertEqual(grid.component, 'tailbone-grid') - - # can override with param - grid = self.make_grid('foo', vue_tagname='something-else') - self.assertEqual(grid.component, 'something-else') - - # can still pass old param - grid = self.make_grid('foo', component='legacy-name') - self.assertEqual(grid.component, 'legacy-name') - - def test_component_studly(self): - - # default - grid = self.make_grid('foo') - self.assertEqual(grid.component_studly, 'TailboneGrid') - - # can override with param - grid = self.make_grid('foo', vue_tagname='something-else') - self.assertEqual(grid.component_studly, 'SomethingElse') - - # can still pass old param - grid = self.make_grid('foo', component='legacy-name') - self.assertEqual(grid.component_studly, 'LegacyName') - - def test_actions(self): - - # default - grid = self.make_grid('foo') - self.assertEqual(grid.actions, []) - - # main actions - grid = self.make_grid('foo', main_actions=['foo']) - self.assertEqual(grid.actions, ['foo']) - - # more actions - grid = self.make_grid('foo', main_actions=['foo'], more_actions=['bar']) - self.assertEqual(grid.actions, ['foo', 'bar']) - - def test_set_label(self): - model = self.app.model - grid = self.make_grid(model_class=model.Setting, filterable=True) - self.assertEqual(grid.labels, {}) - - # basic - grid.set_label('name', "NAME COL") - self.assertEqual(grid.labels['name'], "NAME COL") - - # can replace label - grid.set_label('name', "Different") - self.assertEqual(grid.labels['name'], "Different") - self.assertEqual(grid.get_label('name'), "Different") - - # can update only column, not filter - self.assertEqual(grid.labels, {'name': "Different"}) - self.assertIn('name', grid.filters) - self.assertEqual(grid.filters['name'].label, "Different") - grid.set_label('name', "COLUMN ONLY", column_only=True) - self.assertEqual(grid.get_label('name'), "COLUMN ONLY") - self.assertEqual(grid.filters['name'].label, "Different") - - def test_get_view_click_handler(self): - model = self.app.model - grid = self.make_grid(model_class=model.Setting) - - grid.actions.append( - mod.GridAction(self.request, 'view', - click_handler='clickHandler(props.row)')) - - handler = grid.get_view_click_handler() - self.assertEqual(handler, 'clickHandler(props.row)') - - def test_set_action_urls(self): - model = self.app.model - grid = self.make_grid(model_class=model.Setting) - - grid.actions.append( - mod.GridAction(self.request, 'view', url='/blarg')) - - setting = {'name': 'foo', 'value': 'bar'} - grid.set_action_urls(setting, setting, 0) - self.assertEqual(setting['_action_url_view'], '/blarg') - - def test_default_sortkey(self): - grid = self.make_grid() - self.assertEqual(grid.sort_defaults, []) - self.assertIsNone(grid.default_sortkey) - grid.default_sortkey = 'name' - self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')]) - self.assertEqual(grid.default_sortkey, 'name') - grid.default_sortkey = 'value' - self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')]) - self.assertEqual(grid.default_sortkey, 'value') - - def test_default_sortdir(self): - grid = self.make_grid() - self.assertEqual(grid.sort_defaults, []) - self.assertIsNone(grid.default_sortdir) - self.assertRaises(ValueError, setattr, grid, 'default_sortdir', 'asc') - grid.sort_defaults = [mod.SortInfo('name', 'asc')] - grid.default_sortdir = 'desc' - self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) - self.assertEqual(grid.default_sortdir, 'desc') - - def test_pageable(self): - grid = self.make_grid() - self.assertFalse(grid.paginated) - grid.pageable = True - self.assertTrue(grid.paginated) - grid.paginated = False - self.assertFalse(grid.pageable) - - def test_get_pagesize_options(self): - grid = self.make_grid() - - # default - options = grid.get_pagesize_options() - self.assertEqual(options, [5, 10, 20, 50, 100, 200]) - - # override default - options = grid.get_pagesize_options(default=[42]) - self.assertEqual(options, [42]) - - # from legacy config - self.config.setdefault('tailbone.grid.pagesize_options', '1 2 3') - grid = self.make_grid() - options = grid.get_pagesize_options() - self.assertEqual(options, [1, 2, 3]) - - # from new config - self.config.setdefault('wuttaweb.grids.default_pagesize_options', '4, 5, 6') - grid = self.make_grid() - options = grid.get_pagesize_options() - self.assertEqual(options, [4, 5, 6]) - - def test_get_pagesize(self): - grid = self.make_grid() - - # default - size = grid.get_pagesize() - self.assertEqual(size, 20) - - # override default - size = grid.get_pagesize(default=42) - self.assertEqual(size, 42) - - # override default options - self.config.setdefault('wuttaweb.grids.default_pagesize_options', '10 15 30') - grid = self.make_grid() - size = grid.get_pagesize() - self.assertEqual(size, 10) - - # from legacy config - self.config.setdefault('tailbone.grid.default_pagesize', '12') - grid = self.make_grid() - size = grid.get_pagesize() - self.assertEqual(size, 12) - - # from new config - self.config.setdefault('wuttaweb.grids.default_pagesize', '15') - grid = self.make_grid() - size = grid.get_pagesize() - self.assertEqual(size, 15) - - def test_set_sorter(self): - model = self.app.model - grid = self.make_grid(model_class=model.Setting, - sortable=True, sort_on_backend=True) - - # passing None will remove sorter - self.assertIn('name', grid.sorters) - grid.set_sorter('name', None) - self.assertNotIn('name', grid.sorters) - - # can recreate sorter with just column name - grid.set_sorter('name') - self.assertIn('name', grid.sorters) - grid.remove_sorter('name') - self.assertNotIn('name', grid.sorters) - grid.set_sorter('name', 'name') - self.assertIn('name', grid.sorters) - - # can recreate sorter with model property - grid.remove_sorter('name') - self.assertNotIn('name', grid.sorters) - grid.set_sorter('name', model.Setting.name) - self.assertIn('name', grid.sorters) - - # extra kwargs are ignored - grid.remove_sorter('name') - self.assertNotIn('name', grid.sorters) - grid.set_sorter('name', model.Setting.name, foo='bar') - self.assertIn('name', grid.sorters) - - # passing multiple args will invoke make_filter() directly - grid.remove_sorter('name') - self.assertNotIn('name', grid.sorters) - with patch.object(grid, 'make_sorter') as make_sorter: - make_sorter.return_value = 42 - grid.set_sorter('name', 'foo', 'bar') - make_sorter.assert_called_once_with('foo', 'bar') - self.assertEqual(grid.sorters['name'], 42) - - def test_make_simple_sorter(self): - model = self.app.model - grid = self.make_grid(model_class=model.Setting, - sortable=True, sort_on_backend=True) - - # delegates to grid.make_sorter() - with patch.object(grid, 'make_sorter') as make_sorter: - make_sorter.return_value = 42 - sorter = grid.make_simple_sorter('name', foldcase=True) - make_sorter.assert_called_once_with('name', foldcase=True) - self.assertEqual(sorter, 42) - - def test_load_settings(self): - model = self.app.model - - # nb. first use a paging grid - grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True, - pagesize=20, page=1) - - # settings are loaded, applied, saved - self.assertEqual(grid.page, 1) - self.assertNotIn('grid.foo.page', self.request.session) - self.request.GET = {'pagesize': '10', 'page': '2'} - grid.load_settings() - self.assertEqual(grid.page, 2) - self.assertEqual(self.request.session['grid.foo.page'], 2) - - # can skip the saving step - self.request.GET = {'pagesize': '10', 'page': '3'} - grid.load_settings(store=False) - self.assertEqual(grid.page, 3) - self.assertEqual(self.request.session['grid.foo.page'], 2) - - # no error for non-paginated grid - grid = self.make_grid(key='foo', paginated=False) - grid.load_settings() - self.assertFalse(grid.paginated) - - # nb. next use a sorting grid - grid = self.make_grid(key='settings', model_class=model.Setting, - sortable=True, sort_on_backend=True) - - # settings are loaded, applied, saved - self.assertEqual(grid.sort_defaults, []) - self.assertFalse(hasattr(grid, 'active_sorters')) - self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'} - grid.load_settings() - self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) - self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) - self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') - self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') - - # can skip the saving step - self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'} - grid.load_settings(store=False) - self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) - self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) - self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') - self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') - - # no error for non-sortable grid - grid = self.make_grid(key='foo', sortable=False) - grid.load_settings() - self.assertFalse(grid.sortable) - - # with sort defaults - grid = self.make_grid(model_class=model.Setting, sortable=True, - sort_on_backend=True, sort_defaults='name') - self.assertFalse(hasattr(grid, 'active_sorters')) - grid.load_settings() - self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) - - # with multi-column sort defaults - grid = self.make_grid(model_class=model.Setting, sortable=True, - sort_on_backend=True) - grid.sort_defaults = [ - mod.SortInfo('name', 'asc'), - mod.SortInfo('value', 'desc'), - ] - self.assertFalse(hasattr(grid, 'active_sorters')) - grid.load_settings() - self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) - - # load settings from session when nothing is in request - self.request.GET = {} - self.request.session.invalidate() - self.assertNotIn('grid.settings.sorters.length', self.request.session) - self.request.session['grid.settings.sorters.length'] = 1 - self.request.session['grid.settings.sorters.1.key'] = 'name' - self.request.session['grid.settings.sorters.1.dir'] = 'desc' - grid = self.make_grid(key='settings', model_class=model.Setting, - sortable=True, sort_on_backend=True, - paginated=True, paginate_on_backend=True) - self.assertFalse(hasattr(grid, 'active_sorters')) - grid.load_settings() - self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) - - def test_persist_settings(self): - model = self.app.model - - # nb. start out with paginated-only grid - grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True) - - # invalid dest - self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist') - - # nb. no error if empty settings, but it saves null values - grid.persist_settings({}, dest='session') - self.assertIsNone(self.request.session['grid.foo.page']) - - # provided values are saved - grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session') - self.assertEqual(self.request.session['grid.foo.page'], 3) - - # nb. now switch to sortable-only grid - grid = self.make_grid(key='settings', model_class=model.Setting, - sortable=True, sort_on_backend=True) - - # no error if empty settings; does not save values - grid.persist_settings({}, dest='session') - self.assertNotIn('grid.settings.sorters.length', self.request.session) - - # provided values are saved - grid.persist_settings({'sorters.length': 2, - 'sorters.1.key': 'name', - 'sorters.1.dir': 'desc', - 'sorters.2.key': 'value', - 'sorters.2.dir': 'asc'}, - dest='session') - self.assertEqual(self.request.session['grid.settings.sorters.length'], 2) - self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') - self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') - self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value') - self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc') - - # old values removed when new are saved - grid.persist_settings({'sorters.length': 1, - 'sorters.1.key': 'name', - 'sorters.1.dir': 'desc'}, - dest='session') - self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) - self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') - self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc') - self.assertNotIn('grid.settings.sorters.2.key', self.request.session) - self.assertNotIn('grid.settings.sorters.2.dir', self.request.session) - - def test_sort_data(self): - model = self.app.model - sample_data = [ - {'name': 'foo1', 'value': 'ONE'}, - {'name': 'foo2', 'value': 'two'}, - {'name': 'foo3', 'value': 'ggg'}, - {'name': 'foo4', 'value': 'ggg'}, - {'name': 'foo5', 'value': 'ggg'}, - {'name': 'foo6', 'value': 'six'}, - {'name': 'foo7', 'value': 'seven'}, - {'name': 'foo8', 'value': 'eight'}, - {'name': 'foo9', 'value': 'nine'}, - ] - for setting in sample_data: - self.app.save_setting(self.session, setting['name'], setting['value']) - self.session.commit() - sample_query = self.session.query(model.Setting) - - grid = self.make_grid(model_class=model.Setting, - sortable=True, sort_on_backend=True, - sort_defaults=('name', 'desc')) - grid.load_settings() - - # can sort a simple list of data - sorted_data = grid.sort_data(sample_data) - self.assertIsInstance(sorted_data, list) - self.assertEqual(len(sorted_data), 9) - self.assertEqual(sorted_data[0]['name'], 'foo9') - self.assertEqual(sorted_data[-1]['name'], 'foo1') - - # can also sort a data query - sorted_query = grid.sort_data(sample_query) - self.assertIsInstance(sorted_query, orm.Query) - sorted_data = sorted_query.all() - self.assertEqual(len(sorted_data), 9) - self.assertEqual(sorted_data[0]['name'], 'foo9') - self.assertEqual(sorted_data[-1]['name'], 'foo1') - - # cannot sort data if sorter missing in overrides - sorted_data = grid.sort_data(sample_data, sorters=[]) - # nb. sorted data is in same order as original sample (not sorted) - self.assertEqual(sorted_data[0]['name'], 'foo1') - self.assertEqual(sorted_data[-1]['name'], 'foo9') - - # multi-column sorting for list data - sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'}, - {'key': 'name', 'dir': 'asc'}]) - self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) - self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'}) - self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'}) - self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'}) - - # multi-column sorting for query - sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'}, - {'key': 'name', 'dir': 'asc'}]) - self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) - self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'}) - self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'}) - self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'}) - - # cannot sort data if sortfunc is missing for column - grid.remove_sorter('name') - sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'}, - {'key': 'name', 'dir': 'asc'}]) - # nb. sorted data is in same order as original sample (not sorted) - self.assertEqual(sorted_data[0]['name'], 'foo1') - self.assertEqual(sorted_data[-1]['name'], 'foo9') - - def test_render_vue_tag(self): - model = self.app.model - - # standard - grid = self.make_grid('settings', model_class=model.Setting) - html = grid.render_vue_tag() - self.assertIn('<tailbone-grid', html) - self.assertNotIn('@deleteActionClicked', html) - - # with delete hook - master = MagicMock(deletable=True, delete_confirm='simple') - master.has_perm.return_value = True - grid = self.make_grid('settings', model_class=model.Setting) - html = grid.render_vue_tag(master=master) - self.assertIn('<tailbone-grid', html) - self.assertIn('@deleteActionClicked', html) - - def test_render_vue_template(self): - # self.pyramid_config.include('tailbone.views.common') - model = self.app.model - - # sanity check - grid = self.make_grid('settings', model_class=model.Setting) - html = grid.render_vue_template(session=self.session) - self.assertIn('<b-table', html) - - def test_get_vue_columns(self): - model = self.app.model - - # sanity check - grid = self.make_grid('settings', model_class=model.Setting, sortable=True) - columns = grid.get_vue_columns() - self.assertEqual(len(columns), 2) - self.assertEqual(columns[0]['field'], 'name') - self.assertTrue(columns[0]['sortable']) - self.assertEqual(columns[1]['field'], 'value') - self.assertTrue(columns[1]['sortable']) - - def test_get_vue_data(self): - model = self.app.model - - # sanity check - grid = self.make_grid('settings', model_class=model.Setting) - data = grid.get_vue_data() - self.assertEqual(data, []) - - # calling again returns same data - data2 = grid.get_vue_data() - self.assertIs(data2, data) - - -class TestGridAction(WebTestCase): - - def test_constructor(self): - - # null by default - action = mod.GridAction(self.request, 'view') - self.assertIsNone(action.target) - self.assertIsNone(action.click_handler) - - # but can set them - action = mod.GridAction(self.request, 'view', - target='_blank', - click_handler='doSomething(props.row)') - self.assertEqual(action.target, '_blank') - self.assertEqual(action.click_handler, 'doSomething(props.row)') diff --git a/tests/test_app.py b/tests/test_app.py index f49f6b13..2523c424 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,11 +3,14 @@ import os from unittest import TestCase -from pyramid.config import Configurator +from sqlalchemy import create_engine +from rattail.config import RattailConfig from rattail.exceptions import ConfigurationError -from rattail.testing import DataTestCase -from tailbone import app as mod +from rattail.db import Session as RattailSession + +from tailbone import app +from tailbone.db import Session as TailboneSession class TestRattailConfig(TestCase): @@ -15,34 +18,15 @@ class TestRattailConfig(TestCase): config_path = os.path.abspath( os.path.join(os.path.dirname(__file__), 'data', 'tailbone.conf')) + def tearDown(self): + # may or may not be necessary depending on test + TailboneSession.remove() + def test_settings_arg_must_include_config_path_by_default(self): # error raised if path not provided - self.assertRaises(ConfigurationError, mod.make_rattail_config, {}) + self.assertRaises(ConfigurationError, app.make_rattail_config, {}) # get a config object if path provided - result = mod.make_rattail_config({'rattail.config': self.config_path}) + result = app.make_rattail_config({'rattail.config': self.config_path}) # nb. cannot test isinstance(RattailConfig) b/c now uses wrapper! self.assertIsNotNone(result) self.assertTrue(hasattr(result, 'get')) - - -class TestMakePyramidConfig(DataTestCase): - - def make_config(self, **kwargs): - myconf = self.write_file('web.conf', """ -[rattail.db] -default.url = sqlite:// -""") - - self.settings = { - 'rattail.config': myconf, - 'mako.directories': 'tailbone:templates', - } - return mod.make_rattail_config(self.settings) - - def test_basic(self): - model = self.app.model - model.Base.metadata.create_all(bind=self.config.appdb_engine) - - # sanity check - pyramid_config = mod.make_pyramid_config(self.settings) - self.assertIsInstance(pyramid_config, Configurator) diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index 4519e152..00000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,3 +0,0 @@ -# -*- coding: utf-8; -*- - -from tailbone import auth as mod diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 0cd1938c..00000000 --- a/tests/test_config.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8; -*- - -from tailbone import config as mod -from tests.util import DataTestCase - - -class TestConfigExtension(DataTestCase): - - def test_basic(self): - # sanity / coverage check - ext = mod.ConfigExtension() - ext.configure(self.config) diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py deleted file mode 100644 index 81bc2869..00000000 --- a/tests/test_subscribers.py +++ /dev/null @@ -1,58 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest.mock import MagicMock - -from pyramid import testing - -from tailbone import subscribers as mod -from tests.util import DataTestCase - - -class TestNewRequest(DataTestCase): - - def setUp(self): - self.setup_db() - self.request = self.make_request() - self.pyramid_config = testing.setUp(request=self.request, settings={ - 'wutta_config': self.config, - }) - - def tearDown(self): - self.teardown_db() - testing.tearDown() - - def make_request(self, **kwargs): - return testing.DummyRequest(**kwargs) - - def make_event(self): - return MagicMock(request=self.request) - - def test_continuum_remote_addr(self): - event = self.make_event() - - # nothing happens - mod.new_request(event, session=self.session) - self.assertFalse(hasattr(self.session, 'continuum_remote_addr')) - - # unless request has client_addr - self.request.client_addr = '127.0.0.1' - mod.new_request(event, session=self.session) - self.assertEqual(self.session.continuum_remote_addr, '127.0.0.1') - - def test_register_component(self): - event = self.make_event() - - # function added - self.assertFalse(hasattr(self.request, 'register_component')) - mod.new_request(event, session=self.session) - self.assertTrue(callable(self.request.register_component)) - - # call function - self.request.register_component('tailbone-datepicker', 'TailboneDatepicker') - self.assertEqual(self.request._tailbone_registered_components, - {'tailbone-datepicker': 'TailboneDatepicker'}) - - # duplicate registration ignored - self.request.register_component('tailbone-datepicker', 'TailboneDatepicker') - self.assertEqual(self.request._tailbone_registered_components, - {'tailbone-datepicker': 'TailboneDatepicker'}) diff --git a/tests/test_util.py b/tests/test_util.py deleted file mode 100644 index 46684f0c..00000000 --- a/tests/test_util.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest import TestCase - -from pyramid import testing - -from rattail.config import RattailConfig - -from tailbone import util - - -class TestGetFormData(TestCase): - - def setUp(self): - self.config = RattailConfig() - - def make_request(self, **kwargs): - kwargs.setdefault('wutta_config', self.config) - kwargs.setdefault('rattail_config', self.config) - kwargs.setdefault('is_xhr', None) - kwargs.setdefault('content_type', None) - kwargs.setdefault('POST', {'foo1': 'bar'}) - kwargs.setdefault('json_body', {'foo2': 'baz'}) - return testing.DummyRequest(**kwargs) - - def test_default(self): - request = self.make_request() - data = util.get_form_data(request) - self.assertEqual(data, {'foo1': 'bar'}) - - def test_is_xhr(self): - request = self.make_request(POST=None, is_xhr=True) - data = util.get_form_data(request) - self.assertEqual(data, {'foo2': 'baz'}) - - def test_content_type(self): - request = self.make_request(POST=None, content_type='application/json') - data = util.get_form_data(request) - self.assertEqual(data, {'foo2': 'baz'}) diff --git a/tests/util.py b/tests/util.py deleted file mode 100644 index 4277a7c3..00000000 --- a/tests/util.py +++ /dev/null @@ -1,77 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest.mock import MagicMock - -from pyramid import testing - -from tailbone import subscribers -from wuttaweb.menus import MenuHandler -# from wuttaweb.subscribers import new_request_set_user -from rattail.testing import DataTestCase - - -class WebTestCase(DataTestCase): - """ - Base class for test suites requiring a full (typical) web app. - """ - - def setUp(self): - self.setup_web() - - def setup_web(self): - self.setup_db() - self.request = self.make_request() - self.pyramid_config = testing.setUp(request=self.request, settings={ - 'wutta_config': self.config, - 'rattail_config': self.config, - 'mako.directories': ['tailbone:templates', 'wuttaweb:templates'], - # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', - }) - - # init web - # self.pyramid_config.include('pyramid_deform') - self.pyramid_config.include('pyramid_mako') - self.pyramid_config.add_directive('add_wutta_permission_group', - 'wuttaweb.auth.add_permission_group') - self.pyramid_config.add_directive('add_wutta_permission', - 'wuttaweb.auth.add_permission') - self.pyramid_config.add_directive('add_tailbone_permission_group', - 'wuttaweb.auth.add_permission_group') - self.pyramid_config.add_directive('add_tailbone_permission', - 'wuttaweb.auth.add_permission') - self.pyramid_config.add_directive('add_tailbone_index_page', - 'tailbone.app.add_index_page') - self.pyramid_config.add_directive('add_tailbone_model_view', - 'tailbone.app.add_model_view') - self.pyramid_config.add_directive('add_tailbone_config_page', - 'tailbone.app.add_config_page') - self.pyramid_config.add_subscriber('tailbone.subscribers.before_render', - 'pyramid.events.BeforeRender') - self.pyramid_config.include('tailbone.static') - - # setup new request w/ anonymous user - event = MagicMock(request=self.request) - subscribers.new_request(event, session=self.session) - # def user_getter(request, **kwargs): pass - # new_request_set_user(event, db_session=self.session, - # user_getter=user_getter) - - def tearDown(self): - self.teardown_web() - - def teardown_web(self): - testing.tearDown() - self.teardown_db() - - def make_request(self, **kwargs): - kwargs.setdefault('rattail_config', self.config) - # kwargs.setdefault('wutta_config', self.config) - return testing.DummyRequest(**kwargs) - - -class NullMenuHandler(MenuHandler): - """ - Dummy menu handler for testing. - """ - def make_menus(self, request, **kwargs): - return [] diff --git a/tests/views/test_master.py b/tests/views/test_master.py deleted file mode 100644 index 0e459e7d..00000000 --- a/tests/views/test_master.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest.mock import patch, MagicMock - -from tailbone.views import master as mod -from wuttaweb.grids import GridAction -from tests.util import WebTestCase - - -class TestMasterView(WebTestCase): - - def make_view(self): - return mod.MasterView(self.request) - - def test_make_form_kwargs(self): - self.pyramid_config.add_route('settings.view', '/settings/{name}') - model = self.app.model - setting = model.Setting(name='foo', value='bar') - self.session.add(setting) - self.session.commit() - with patch.multiple(mod.MasterView, create=True, - model_class=model.Setting): - view = self.make_view() - - # sanity / coverage check - kw = view.make_form_kwargs(model_instance=setting) - self.assertIsNotNone(kw['action_url']) - - def test_make_action(self): - model = self.app.model - with patch.multiple(mod.MasterView, create=True, - model_class=model.Setting): - view = self.make_view() - action = view.make_action('view') - self.assertIsInstance(action, GridAction) - - def test_index(self): - self.pyramid_config.include('tailbone.views.common') - self.pyramid_config.include('tailbone.views.auth') - model = self.app.model - - # mimic view for /settings - with patch.object(mod, 'Session', return_value=self.session): - with patch.multiple(mod.MasterView, create=True, - model_class=model.Setting, - Session=MagicMock(return_value=self.session), - get_index_url=MagicMock(return_value='/settings/'), - get_help_url=MagicMock(return_value=None)): - - # basic - view = self.make_view() - response = view.index() - self.assertEqual(response.status_code, 200) - - # then again with data, to include view action url - data = [{'name': 'foo', 'value': 'bar'}] - with patch.object(view, 'get_data', return_value=data): - response = view.index() - self.assertEqual(response.status_code, 200) - self.assertEqual(response.content_type, 'text/html') - - # then once more as 'partial' - aka. data only - self.request.GET = {'partial': '1'} - response = view.index() - self.assertEqual(response.status_code, 200) - self.assertEqual(response.content_type, 'application/json') diff --git a/tests/views/test_people.py b/tests/views/test_people.py deleted file mode 100644 index f85577e7..00000000 --- a/tests/views/test_people.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8; -*- - -from tailbone.views import users as mod -from tests.util import WebTestCase - - -class TestPersonView(WebTestCase): - - def make_view(self): - return mod.PersonView(self.request) - - def test_includeme(self): - self.pyramid_config.include('tailbone.views.people') - - def test_includeme_wutta(self): - self.config.setdefault('tailbone.use_wutta_views', 'true') - self.pyramid_config.include('tailbone.views.people') diff --git a/tests/views/test_principal.py b/tests/views/test_principal.py deleted file mode 100644 index 2b31531c..00000000 --- a/tests/views/test_principal.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest.mock import patch, MagicMock - -from tailbone.views import principal as mod -from tests.util import WebTestCase - - -class TestPrincipalMasterView(WebTestCase): - - def make_view(self): - return mod.PrincipalMasterView(self.request) - - def test_find_by_perm(self): - model = self.app.model - self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') - self.pyramid_config.include('tailbone.views.common') - self.pyramid_config.include('tailbone.views.auth') - self.pyramid_config.add_route('roles', '/roles/') - with patch.multiple(mod.PrincipalMasterView, create=True, - model_class=model.Role, - get_help_url=MagicMock(return_value=None), - get_help_markdown=MagicMock(return_value=None), - can_edit_help=MagicMock(return_value=False)): - - # sanity / coverage check - view = self.make_view() - response = view.find_by_perm() - self.assertEqual(response.status_code, 200) diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py deleted file mode 100644 index 0cdc724e..00000000 --- a/tests/views/test_roles.py +++ /dev/null @@ -1,80 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest.mock import patch - -from tailbone.views import roles as mod -from tests.util import WebTestCase - - -class TestRoleView(WebTestCase): - - def make_view(self): - return mod.RoleView(self.request) - - def test_includeme(self): - self.pyramid_config.include('tailbone.views.roles') - - def get_permissions(self): - return { - 'widgets': { - 'label': "Widgets", - 'perms': { - 'widgets.list': { - 'label': "List widgets", - }, - 'widgets.polish': { - 'label': "Polish the widgets", - }, - 'widgets.view': { - 'label': "View widget", - }, - }, - }, - } - - def test_get_available_permissions(self): - model = self.app.model - auth = self.app.get_auth_handler() - blokes = model.Role(name="Blokes") - auth.grant_permission(blokes, 'widgets.list') - self.session.add(blokes) - barney = model.User(username='barney') - barney.roles.append(blokes) - self.session.add(barney) - self.session.commit() - view = self.make_view() - all_perms = self.get_permissions() - self.request.registry.settings['wutta_permissions'] = all_perms - - def has_perm(perm): - if perm == 'widgets.list': - return True - return False - - with patch.object(self.request, 'has_perm', new=has_perm, create=True): - - # sanity check; current request has 1 perm - self.assertTrue(self.request.has_perm('widgets.list')) - self.assertFalse(self.request.has_perm('widgets.polish')) - self.assertFalse(self.request.has_perm('widgets.view')) - - # when editing, user sees only the 1 perm - with patch.object(view, 'editing', new=True): - perms = view.get_available_permissions() - self.assertEqual(list(perms), ['widgets']) - self.assertEqual(list(perms['widgets']['perms']), ['widgets.list']) - - # but when viewing, same user sees all perms - with patch.object(view, 'viewing', new=True): - perms = view.get_available_permissions() - self.assertEqual(list(perms), ['widgets']) - self.assertEqual(list(perms['widgets']['perms']), - ['widgets.list', 'widgets.polish', 'widgets.view']) - - # also, when admin user is editing, sees all perms - self.request.is_admin = True - with patch.object(view, 'editing', new=True): - perms = view.get_available_permissions() - self.assertEqual(list(perms), ['widgets']) - self.assertEqual(list(perms['widgets']['perms']), - ['widgets.list', 'widgets.polish', 'widgets.view']) diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py deleted file mode 100644 index b8523729..00000000 --- a/tests/views/test_settings.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8; -*- - -from tailbone.views import settings as mod -from tests.util import WebTestCase - - -class TestSettingView(WebTestCase): - - def test_includeme(self): - self.pyramid_config.include('tailbone.views.settings') diff --git a/tests/views/test_users.py b/tests/views/test_users.py deleted file mode 100644 index 4b94caf2..00000000 --- a/tests/views/test_users.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest.mock import patch, MagicMock - -from tailbone.views import users as mod -from tailbone.views.principal import PermissionsRenderer -from tests.util import WebTestCase - - -class TestUserView(WebTestCase): - - def make_view(self): - return mod.UserView(self.request) - - def test_includeme(self): - self.pyramid_config.include('tailbone.views.users') - - def test_configure_form(self): - self.pyramid_config.include('tailbone.views.users') - model = self.app.model - barney = model.User(username='barney') - self.session.add(barney) - self.session.commit() - view = self.make_view() - - # must use mock configure when making form - def configure(form): pass - form = view.make_form(instance=barney, configure=configure) - - with patch.object(view, 'viewing', new=True): - self.assertNotIn('permissions', form.renderers) - view.configure_form(form) - self.assertIsInstance(form.renderers['permissions'], PermissionsRenderer) diff --git a/tests/views/wutta/__init__.py b/tests/views/wutta/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py deleted file mode 100644 index 31aeb501..00000000 --- a/tests/views/wutta/test_people.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest.mock import patch - -from sqlalchemy import orm - -from tailbone.views.wutta import people as mod -from tests.util import WebTestCase - - -class TestPersonView(WebTestCase): - - def make_view(self): - return mod.PersonView(self.request) - - def test_includeme(self): - self.pyramid_config.include('tailbone.views.wutta.people') - - def test_get_query(self): - view = self.make_view() - - # sanity / coverage check - query = view.get_query(session=self.session) - self.assertIsInstance(query, orm.Query) - - def test_configure_grid(self): - model = self.app.model - barney = model.User(username='barney') - self.session.add(barney) - self.session.commit() - view = self.make_view() - - # sanity / coverage check - grid = view.make_grid(model_class=model.Person) - self.assertNotIn('first_name', grid.linked_columns) - view.configure_grid(grid) - self.assertIn('first_name', grid.linked_columns) - - def test_configure_form(self): - model = self.app.model - barney = model.Person(display_name="Barney Rubble") - self.session.add(barney) - self.session.commit() - view = self.make_view() - - # email field remains when viewing - with patch.object(view, 'viewing', new=True): - form = view.make_form(model_instance=barney, - fields=view.get_form_fields()) - self.assertIn('email', form.fields) - view.configure_form(form) - self.assertIn('email', form) - - # email field removed when editing - with patch.object(view, 'editing', new=True): - form = view.make_form(model_instance=barney, - fields=view.get_form_fields()) - self.assertIn('email', form.fields) - view.configure_form(form) - self.assertNotIn('email', form) - - def test_render_merge_requested(self): - model = self.app.model - barney = model.Person(display_name="Barney Rubble") - self.session.add(barney) - user = model.User(username='user') - self.session.add(user) - self.session.commit() - view = self.make_view() - - # null by default - html = view.render_merge_requested(barney, 'merge_requested', None, - session=self.session) - self.assertIsNone(html) - - # unless a merge request exists - barney2 = model.Person(display_name="Barney Rubble") - self.session.add(barney2) - self.session.commit() - mr = model.MergePeopleRequest(removing_uuid=barney2.uuid, - keeping_uuid=barney.uuid, - requested_by=user) - self.session.add(mr) - self.session.commit() - html = view.render_merge_requested(barney, 'merge_requested', None, - session=self.session) - self.assertIn('<span ', html)