From f6f2a53a0c7a542ead7bb5dc9a8414e6057ed774 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 6 Jun 2024 20:33:36 -0500 Subject: [PATCH 001/211] Use `pkg_resources` to determine package versions and always add `app_version` to global template context. this was for sake of "About This App v1.0.0" style links in custom page footers --- tailbone/subscribers.py | 5 +++++ tailbone/views/common.py | 5 ++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 42d3cab7..59ef64dc 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -28,6 +28,7 @@ import six import json import datetime import logging +import pkg_resources import warnings from collections import OrderedDict @@ -168,7 +169,11 @@ def before_render(event): renderer_globals = event renderer_globals['rattail_app'] = request.rattail_config.get_app() + renderer_globals['app_title'] = request.rattail_config.app_title() + pkg = rattail_config.app_package() + renderer_globals['app_version'] = pkg_resources.get_distribution(pkg).version + renderer_globals['h'] = helpers renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 266561fd..58346f3b 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -24,8 +24,8 @@ Various common views """ -import importlib import os +import pkg_resources from collections import OrderedDict from rattail.batch import consume_batch_id @@ -108,8 +108,7 @@ class CommonView(View): return self.project_version pkg = self.rattail_config.app_package() - mod = importlib.import_module(pkg) - return mod.__version__ + return pkg_resources.get_distribution(pkg).version def exception(self): """ From 0491d8517c59379bf6e272ba1dc93245e6c82930 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 6 Jun 2024 23:04:47 -0500 Subject: [PATCH 002/211] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cc04273e..178135e4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ CHANGELOG Unreleased ---------- +0.10.14 (2024-06-06) +-------------------- + +* Use ``pkg_resources`` to determine package versions. + + 0.10.13 (2024-06-06) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 1daf8e32..7a64f8d0 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.13' +__version__ = '0.10.14' From 94d7836321b34d9892f3deace05c7d369c07808f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 6 Jun 2024 23:05:40 -0500 Subject: [PATCH 003/211] Ignore dist folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 906dc226..03545d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .coverage .tox/ +dist/ docs/_build/ htmlcov/ Tailbone.egg-info/ From 610e1666c01b48a5aae4990d51aa1cafc04d60ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jun 2024 10:07:31 -0500 Subject: [PATCH 004/211] Revert "Use `pkg_resources` to determine package versions" This reverts commit f6f2a53a0c7a542ead7bb5dc9a8414e6057ed774. --- tailbone/subscribers.py | 5 ----- tailbone/views/common.py | 5 +++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 59ef64dc..42d3cab7 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -28,7 +28,6 @@ import six import json import datetime import logging -import pkg_resources import warnings from collections import OrderedDict @@ -169,11 +168,7 @@ def before_render(event): renderer_globals = event renderer_globals['rattail_app'] = request.rattail_config.get_app() - renderer_globals['app_title'] = request.rattail_config.app_title() - pkg = rattail_config.app_package() - renderer_globals['app_version'] = pkg_resources.get_distribution(pkg).version - renderer_globals['h'] = helpers renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 58346f3b..266561fd 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -24,8 +24,8 @@ Various common views """ +import importlib import os -import pkg_resources from collections import OrderedDict from rattail.batch import consume_batch_id @@ -108,7 +108,8 @@ class CommonView(View): return self.project_version pkg = self.rattail_config.app_package() - return pkg_resources.get_distribution(pkg).version + mod = importlib.import_module(pkg) + return mod.__version__ def exception(self): """ From a849d8452b6f9ece02a3df231b2e520caa5fc9f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jun 2024 10:25:14 -0500 Subject: [PATCH 005/211] Update changelog --- CHANGES.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 178135e4..2295867e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ CHANGELOG Unreleased ---------- +0.10.15 (unreleased) +-------------------- + +* Do *not* Use ``pkg_resources`` to determine package versions. + + 0.10.14 (2024-06-06) -------------------- From 7c3d5b46f38876c70d6114b23e678df5e810f6c6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 7 Jun 2024 10:25:48 -0500 Subject: [PATCH 006/211] Update changelog --- CHANGES.rst | 2 +- tailbone/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2295867e..a711be5f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,7 @@ CHANGELOG Unreleased ---------- -0.10.15 (unreleased) +0.10.15 (2024-06-07) -------------------- * Do *not* Use ``pkg_resources`` to determine package versions. diff --git a/tailbone/_version.py b/tailbone/_version.py index 7a64f8d0..f6e50fc4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.14' +__version__ = '0.10.15' From b8ace1eb98b76b93025f84311201a4497076dc06 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 9 Jun 2024 23:07:52 -0500 Subject: [PATCH 007/211] fix: avoid deprecated config methods for app/node title --- tailbone/api/common.py | 11 ++++++----- tailbone/subscribers.py | 5 +++-- tailbone/templates/base_meta.mako | 2 +- tailbone/views/auth.py | 3 ++- tailbone/views/common.py | 11 +++++++---- tailbone/views/settings.py | 3 ++- tailbone/views/upgrades.py | 3 ++- 7 files changed, 23 insertions(+), 15 deletions(-) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 30dfeab1..1dcaff08 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,8 +27,6 @@ Tailbone Web API - "Common" Views from collections import OrderedDict import rattail -from rattail.db import model -from rattail.mail import send_email from cornice import Service from cornice.service import get_services @@ -66,7 +64,8 @@ class CommonView(APIView): } def get_project_title(self): - return self.rattail_config.app_title(default="Tailbone") + app = self.get_rattail_app() + return app.get_title() def get_project_version(self): import tailbone @@ -87,6 +86,8 @@ class CommonView(APIView): """ View to handle user feedback form submits. """ + app = self.get_rattail_app() + model = self.model # TODO: this logic was copied from tailbone.views.common and is largely # identical; perhaps should merge somehow? schema = Feedback().bind(session=Session()) @@ -106,7 +107,7 @@ class CommonView(APIView): data['client_ip'] = self.request.client_addr email_key = data['email_key'] or self.feedback_email_key - send_email(self.rattail_config, email_key, data=data) + app.send_email(email_key, data=data) return {'ok': True} return {'error': "Form did not validate!"} diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 42d3cab7..bc851629 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -165,10 +165,11 @@ def before_render(event): request = event.get('request') or threadlocal.get_current_request() rattail_config = request.rattail_config + app = rattail_config.get_app() renderer_globals = event - renderer_globals['rattail_app'] = request.rattail_config.get_app() - renderer_globals['app_title'] = request.rattail_config.app_title() + renderer_globals['rattail_app'] = app + renderer_globals['app_title'] = app.get_title() renderer_globals['h'] = helpers renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index 07b13e61..00cfdfe9 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8; -*- -<%def name="app_title()">${request.rattail_config.node_title(default="Rattail")}</%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> diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 0f0d1687..7ecdc6cd 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -92,6 +92,7 @@ class AuthenticationView(View): """ The login view, responsible for displaying and handling the login form. """ + app = self.get_rattail_app() referrer = self.request.get_referrer(default=self.request.route_url('home')) # redirect if already logged in @@ -133,7 +134,7 @@ class AuthenticationView(View): 'form': form, 'referrer': referrer, 'image_url': image_url, - 'index_title': self.rattail_config.node_title(), + 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 266561fd..25eb7dee 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -50,6 +50,7 @@ class CommonView(View): """ Home page view. """ + app = self.get_rattail_app() if not self.request.user: if self.rattail_config.getbool('tailbone', 'login_is_home', default=True): raise self.redirect(self.request.route_url('login')) @@ -60,7 +61,7 @@ class CommonView(View): context = { 'image_url': image_url, - 'index_title': self.rattail_config.node_title(), + 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } @@ -99,7 +100,8 @@ class CommonView(View): return response def get_project_title(self): - return self.rattail_config.app_title() + app = self.get_rattail_app() + return app.get_title() def get_project_version(self): @@ -121,11 +123,12 @@ class CommonView(View): """ Generic view to show "about project" info page. """ + app = self.get_rattail_app() return { 'project_title': self.get_project_title(), 'project_version': self.get_project_version(), 'packages': self.get_packages(), - 'index_title': self.rattail_config.node_title(), + 'index_title': app.get_node_title(), } def get_packages(self): @@ -209,7 +212,7 @@ class CommonView(View): raise self.forbidden() app = self.get_rattail_app() - app_title = self.rattail_config.app_title() + app_title = app.get_title() poser_handler = app.get_poser_handler() poser_dir = poser_handler.get_default_poser_dir() poser_dir_exists = os.path.isdir(poser_dir) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index cce5e53d..8d389530 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -67,8 +67,9 @@ class AppInfoView(MasterView): ] def get_index_title(self): + app = self.get_rattail_app() return "{} for {}".format(self.get_model_title_plural(), - self.rattail_config.app_title()) + app.get_title()) def get_data(self, session=None): pip = os.path.join(sys.prefix, 'bin', 'pip') diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index a281062e..3276b64d 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -147,10 +147,11 @@ class UpgradeView(MasterView): def template_kwargs_view(self, **kwargs): kwargs = super().template_kwargs_view(**kwargs) + app = self.get_rattail_app() model = self.model upgrade = kwargs['instance'] - kwargs['system_title'] = self.rattail_config.app_title() + kwargs['system_title'] = app.get_title() if upgrade.system: system = self.upgrade_handler.get_system(upgrade.system) if system: From 2c2727bf6632febc6ec823182498e803c9fd5617 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jun 2024 09:07:10 -0500 Subject: [PATCH 008/211] feat: standardize how app, package versions are determined --- tailbone/api/common.py | 11 +++++------ tailbone/beaker.py | 8 ++++---- tailbone/subscribers.py | 1 + tailbone/views/common.py | 13 +++++-------- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/tailbone/api/common.py b/tailbone/api/common.py index 1dcaff08..6cacfb06 100644 --- a/tailbone/api/common.py +++ b/tailbone/api/common.py @@ -26,13 +26,12 @@ Tailbone Web API - "Common" Views from collections import OrderedDict -import rattail +from rattail.util import get_pkg_version from cornice import Service from cornice.service import get_services from cornice_swagger import CorniceSwagger -import tailbone from tailbone import forms from tailbone.forms.common import Feedback from tailbone.api import APIView, api @@ -68,8 +67,8 @@ class CommonView(APIView): return app.get_title() def get_project_version(self): - import tailbone - return tailbone.__version__ + app = self.get_rattail_app() + return app.get_version() def get_packages(self): """ @@ -77,8 +76,8 @@ class CommonView(APIView): 'about' page. """ return OrderedDict([ - ('rattail', rattail.__version__), - ('Tailbone', tailbone.__version__), + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), ]) @api diff --git a/tailbone/beaker.py b/tailbone/beaker.py index b5d592f1..25a450df 100644 --- a/tailbone/beaker.py +++ b/tailbone/beaker.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,11 +27,11 @@ Note that most of the code for this module was copied from the beaker and pyramid_beaker projects. """ -from __future__ import unicode_literals, absolute_import - import time from pkg_resources import parse_version +from rattail.util import get_pkg_version + import beaker from beaker.session import Session from beaker.util import coerce_session_params @@ -49,7 +49,7 @@ class TailboneSession(Session): "Loads the data from this session from persistent storage" # are we using older version of beaker? - old_beaker = parse_version(beaker.__version__) < parse_version('1.12') + old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12') self.namespace = self.namespace_class(self.id, data_dir=self.data_dir, diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index bc851629..3fcd1017 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -170,6 +170,7 @@ def before_render(event): renderer_globals = event renderer_globals['rattail_app'] = app renderer_globals['app_title'] = app.get_title() + renderer_globals['app_version'] = app.get_version() renderer_globals['h'] = helpers renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 25eb7dee..3c4b659b 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -24,12 +24,11 @@ Various common views """ -import importlib import os from collections import OrderedDict from rattail.batch import consume_batch_id -from rattail.util import simple_error +from rattail.util import get_pkg_version, simple_error from rattail.files import resource_path from tailbone import forms @@ -109,9 +108,8 @@ class CommonView(View): if hasattr(self, 'project_version'): return self.project_version - pkg = self.rattail_config.app_package() - mod = importlib.import_module(pkg) - return mod.__version__ + app = self.get_rattail_app() + return app.get_version() def exception(self): """ @@ -136,10 +134,9 @@ class CommonView(View): Should return the full set of packages which should be displayed on the 'about' page. """ - import rattail, tailbone return OrderedDict([ - ('rattail', rattail.__version__), - ('Tailbone', tailbone.__version__), + ('rattail', get_pkg_version('rattail')), + ('Tailbone', get_pkg_version('Tailbone')), ]) def change_theme(self): From dd58c640fa2a626efb4fc6a729cedf368ba3668f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jun 2024 11:11:06 -0500 Subject: [PATCH 009/211] Update changelog --- CHANGES.rst | 7 +++++++ tailbone/_version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a711be5f..ad65b7bf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,13 @@ CHANGELOG Unreleased ---------- +0.10.16 (2024-06-10) +-------------------- + +* fix: avoid deprecated config methods for app/node title +* feat: standardize how app, package versions are determined + + 0.10.15 (2024-06-07) -------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index f6e50fc4..e1187ee4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.15' +__version__ = '0.10.16' From 1402d437b5900aee406577696c5b02ae0281d5ba Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jun 2024 16:23:38 -0500 Subject: [PATCH 010/211] feat: switch from setup.cfg to pyproject.toml + hatchling --- .gitignore | 2 + pyproject.toml | 101 +++++++++++++++++++++++++++++++++++++++++++ setup.cfg | 101 ------------------------------------------- setup.py | 29 ------------- tailbone/_version.py | 8 +++- tasks.py | 13 +++++- 6 files changed, 122 insertions(+), 132 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 03545d1a..b3006f90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*~ +*.pyc .coverage .tox/ dist/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..7c894886 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,101 @@ + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "Tailbone" +version = "0.10.16" +description = "Backoffice Web Application for Rattail" +readme = "README.rst" +authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] +license = {text = "GNU GPL v3+"} +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Pyramid", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Office/Business", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +dependencies = [ + "asgiref", + "colander", + "ColanderAlchemy", + "cornice", + "cornice-swagger", + "deform", + "humanize", + "Mako", + "markdown", + "openpyxl", + "paginate", + "paginate_sqlalchemy", + "passlib", + "Pillow", + "pyramid>=2", + "pyramid_beaker", + "pyramid_deform", + "pyramid_exclog", + "pyramid_fanstatic", + "pyramid_mako", + "pyramid_retry", + "pyramid_tm", + "rattail[db,bouncer]", + "six", + "sa-filters", + "simplejson", + "transaction", + "waitress", + "WebHelpers2", + "zope.sqlalchemy", +] + + +[project.optional-dependencies] +docs = ["Sphinx", "sphinx-rtd-theme"] +tests = ["coverage", "mock", "pytest", "pytest-cov"] + + +[project.entry-points."paste.app_factory"] +main = "tailbone.app:main" +webapi = "tailbone.webapi:main" + + +[project.entry-points."rattail.cleaners"] +beaker = "tailbone.cleanup:BeakerCleaner" + + +[project.entry-points."rattail.config.extensions"] +tailbone = "tailbone.config:ConfigExtension" + + +[project.urls] +Homepage = "https://rattailproject.org" +Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone" +Issues = "https://redmine.rattailproject.org/projects/tailbone/issues" +Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGES.rst" + + +[tool.commitizen] +version_provider = "pep621" +tag_format = "v$version" +update_changelog_on_bump = true + + +# [tool.hatch.build.targets.wheel] +# packages = ["corepos"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 50c057f9..00000000 --- a/setup.cfg +++ /dev/null @@ -1,101 +0,0 @@ -# -*- coding: utf-8; -*- - -[nosetests] -nocapture = 1 -cover-package = tailbone -cover-erase = 1 -cover-html = 1 -cover-html-dir = htmlcov - -[metadata] -name = Tailbone -version = attr: tailbone.__version__ -author = Lance Edgar -author_email = lance@edbob.org -url = http://rattailproject.org/ -license = GNU GPL v3 -description = Backoffice Web Application for Rattail -long_description = file: README.rst -classifiers = - Development Status :: 4 - Beta - Environment :: Web Environment - Framework :: Pyramid - Intended Audience :: Developers - License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) - Natural Language :: English - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Topic :: Internet :: WWW/HTTP - Topic :: Office/Business - Topic :: Software Development :: Libraries :: Python Modules - - -[options] -install_requires = - asgiref - colander - ColanderAlchemy - cornice - cornice-swagger - deform - humanize - Mako - markdown - openpyxl - paginate - paginate_sqlalchemy - passlib - Pillow - pyramid>=2 - pyramid_beaker - pyramid_deform - pyramid_exclog - pyramid_fanstatic - pyramid_mako - pyramid_retry - pyramid_tm - rattail[db,bouncer] - six - sa-filters - simplejson - transaction - waitress - WebHelpers2 - zope.sqlalchemy - -tests_require = Tailbone[tests] -test_suite = tests -packages = find: -include_package_data = True -zip_safe = False - - -[options.packages.find] -exclude = - tests.* - tests - - -[options.extras_require] -docs = Sphinx; sphinx-rtd-theme -tests = coverage; mock; pytest; pytest-cov - - -[options.entry_points] - -paste.app_factory = - main = tailbone.app:main - webapi = tailbone.webapi:main - -rattail.cleaners = - beaker = tailbone.cleanup:BeakerCleaner - -rattail.config.extensions = - tailbone = tailbone.config:ConfigExtension diff --git a/setup.py b/setup.py deleted file mode 100644 index 5645ddff..00000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar -# -# This file is part of Rattail. -# -# Rattail is free software: you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# Rattail. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Setup script for Tailbone -""" - -from setuptools import setup - -setup() diff --git a/tailbone/_version.py b/tailbone/_version.py index e1187ee4..7095f6c8 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,9 @@ # -*- coding: utf-8; -*- -__version__ = '0.10.16' +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + + +__version__ = version('Tailbone') diff --git a/tasks.py b/tasks.py index fba0b699..e9f47ccd 100644 --- a/tasks.py +++ b/tasks.py @@ -25,13 +25,24 @@ Tasks for Tailbone """ import os +import re import shutil from invoke import task here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'tailbone', '_version.py')).read()) +__version__ = None +pattern = re.compile(r'^version = "(\d+\.\d+\.\d+)"$') +with open(os.path.join(here, 'pyproject.toml'), 'rt') as f: + for line in f: + line = line.rstrip('\n') + match = pattern.match(line) + if match: + __version__ = match.group(1) + break +if not __version__: + raise RuntimeError("could not parse version!") @task From f9cb6cb59bdd525540bc46fc85ff1450bc52d11f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jun 2024 16:40:55 -0500 Subject: [PATCH 011/211] =?UTF-8?q?bump:=20version=200.10.16=20=E2=86=92?= =?UTF-8?q?=200.11.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 218 +++++++++++++++++++++++++++++ CHANGES.rst => docs/OLDCHANGES.rst | 199 +------------------------- docs/changelog.rst | 8 ++ docs/index.rst | 8 ++ pyproject.toml | 4 +- 5 files changed, 243 insertions(+), 194 deletions(-) create mode 100644 CHANGELOG.md rename CHANGES.rst => docs/OLDCHANGES.rst (97%) create mode 100644 docs/changelog.rst diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c51f3fda --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,218 @@ + +# Changelog +All notable changes to Tailbone will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## v0.11.0 (2024-06-10) + +### Feat + +- switch from setup.cfg to pyproject.toml + hatchling + +## v0.10.16 (2024-06-10) + +### Feat + +- standardize how app, package versions are determined + +### Fix + +- avoid deprecated config methods for app/node title + +## v0.10.15 (2024-06-07) + +### Fix + +- do *not* Use `pkg_resources` to determine package versions + +## v0.10.14 (2024-06-06) + +### Fix + +- use `pkg_resources` to determine package versions + +## v0.10.13 (2024-06-06) + +### Feat + +- remove old/unused scaffold for use with `pcreate` + +- add 'fanstatic' support for sake of libcache assets + +## v0.10.12 (2024-06-04) + +### Feat + +- require pyramid 2.x; remove 1.x-style auth policies + +- remove version cap for deform + +- set explicit referrer when changing app theme + +- add `<b-tooltip>` component shim + +- include extra styles from `base_meta` template for butterball + +- include butterball theme by default for new apps + +### Fix + +- fix product lookup component, per butterball + +## v0.10.11 (2024-06-03) + +### Feat + +- fix vue3 refresh bugs for various views + +- fix grid bug for tempmon appliance view, per oruga + +- fix ordering worksheet generator, per butterball + +- fix inventory worksheet generator, per butterball + +## v0.10.10 (2024-06-03) + +### Feat + +- more butterball fixes for "view profile" template + +### Fix + +- fix focus for `<b-select>` shim component + +## v0.10.9 (2024-06-03) + +### Feat + +- let master view control context menu items for page + +- fix the "new custorder" page for butterball + +### Fix + +- fix panel style for PO vs. Invoice breakdown in receiving batch + +## v0.10.8 (2024-06-02) + +### Feat + +- add styling for checked grid rows, per oruga/butterball + +- fix product view template for oruga/butterball + +- allow per-user custom styles for butterball + +- use oruga 0.8.9 by default + +## v0.10.7 (2024-06-01) + +### Feat + +- add setting to allow decimal quantities for receiving + +- log error if registry has no rattail config + +- add column filters for import/export main grid + +- escape all unsafe html for grid data + +- add speedbumps for delete, set preferred email/phone in profile view + +- fix file upload widget for oruga + +### Fix + +- fix overflow when instance header title is too long (butterball) + +## v0.10.6 (2024-05-29) + +### Feat + +- add way to flag organic products within lookup dialog + +- expose db picker for butterball theme + +- expose quickie lookup for butterball theme + +- fix basic problems with people profile view, per butterball + +## v0.10.5 (2024-05-29) + +### Feat + +- add `<tailbone-timepicker>` component for oruga + +## v0.10.4 (2024-05-12) + +### Fix + +- fix styles for grid actions, per butterball + +## v0.10.3 (2024-05-10) + +### Fix + +- fix bug with grid date filters + +## v0.10.2 (2024-05-08) + +### Feat + +- remove version restriction for pyramid_beaker dependency + +- rename some attrs etc. for buefy components used with oruga + +- fix "tools" helper for receiving batch view, per oruga + +- more data type fixes for ``<tailbone-datepicker>`` + +- fix "view receiving row" page, per oruga + +- tweak styles for grid action links, per butterball + +### Fix + +- fix employees grid when viewing department (per oruga) + +- fix login "enter" key behavior, per oruga + +- fix button text for autocomplete + +## v0.10.1 (2024-04-28) + +### Feat + +- sort list of available themes + +- update various icon names for oruga compatibility + +- show "View This" button when cloning a record + +- stop including 'falafel' as available theme + +### Fix + +- fix vertical alignment in main menu bar, for butterball + +- fix upgrade execution logic/UI per oruga + +## v0.10.0 (2024-04-28) + +This version bump is to reflect adding support for Vue 3 + Oruga via +the 'butterball' theme. There is likely more work to be done for that +yet, but it mostly works at this point. + +### Feat + +- misc. template and view logic tweaks (applicable to all themes) for + better patterns, consistency etc. + +- add initial support for Vue 3 + Oruga, via "butterball" theme + + +## Older Releases + +Please see `docs/OLDCHANGES.rst` for older release notes. diff --git a/CHANGES.rst b/docs/OLDCHANGES.rst similarity index 97% rename from CHANGES.rst rename to docs/OLDCHANGES.rst index ad65b7bf..0a802f40 100644 --- a/CHANGES.rst +++ b/docs/OLDCHANGES.rst @@ -2,193 +2,8 @@ CHANGELOG ========= -Unreleased ----------- - -0.10.16 (2024-06-10) --------------------- - -* fix: avoid deprecated config methods for app/node title -* feat: standardize how app, package versions are determined - - -0.10.15 (2024-06-07) --------------------- - -* Do *not* Use ``pkg_resources`` to determine package versions. - - -0.10.14 (2024-06-06) --------------------- - -* Use ``pkg_resources`` to determine package versions. - - -0.10.13 (2024-06-06) --------------------- - -* Remove old/unused scaffold for use with ``pcreate``. - -* Add 'fanstatic' support for sake of libcache assets. - - -0.10.12 (2024-06-04) --------------------- - -* Require pyramid 2.x; remove 1.x-style auth policies. - -* Remove version cap for deform. - -* Set explicit referrer when changing app theme. - -* Add ``<b-tooltip>`` component shim. - -* Include extra styles from ``base_meta`` template for butterball. - -* Fix product lookup component, per butterball. - -* Include butterball theme by default for new apps. - - -0.10.11 (2024-06-03) --------------------- - -* Fix vue3 refresh bugs for various views. - -* Fix grid bug for tempmon appliance view, per oruga. - -* Fix ordering worksheet generator, per butterball. - -* Fix inventory worksheet generator, per butterball. - - -0.10.10 (2024-06-03) --------------------- - -* Fix focus for ``<b-select>`` shim component. - -* More butterball fixes for "view profile" template. - - -0.10.9 (2024-06-03) -------------------- - -* Let master view control context menu items for page. - -* Fix panel style for PO vs. Invoice breakdown in receiving batch. - -* Fix the "new custorder" page for butterball. - - -0.10.8 (2024-06-02) -------------------- - -* Add styling for checked grid rows, per oruga/butterball. - -* Fix product view template for oruga/butterball. - -* Allow per-user custom styles for butterball. - -* Use oruga 0.8.9 by default. - - -0.10.7 (2024-06-01) -------------------- - -* Add setting to allow decimal quantities for receiving. - -* Log error if registry has no rattail config. - -* Add column filters for import/export main grid. - -* Fix overflow when instance header title is too long (butterball). - -* Escape all unsafe html for grid data. - -* Add speedbumps for delete, set preferred email/phone in profile view. - -* Fix file upload widget for oruga. - - -0.10.6 (2024-05-29) -------------------- - -* Add way to flag organic products within lookup dialog. - -* Expose db picker for butterball theme. - -* Expose quickie lookup for butterball theme. - -* Fix basic problems with people profile view, per butterball. - - -0.10.5 (2024-05-29) -------------------- - -* Add ``<tailbone-timepicker>`` component for oruga. - - -0.10.4 (2024-05-12) -------------------- - -* Fix styles for grid actions, per butterball. - - -0.10.3 (2024-05-10) -------------------- - -* Fix bug with grid date filters. - - -0.10.2 (2024-05-08) -------------------- - -* Fix employees grid when viewing department (per oruga). - -* Remove version restriction for pyramid_beaker dependency. - -* Fix login "enter" key behavior, per oruga. - -* Rename some attrs etc. for buefy components used with oruga. - -* Fix "tools" helper for receiving batch view, per oruga. - -* Fix button text for autocomplete. - -* More data type fixes for ``<tailbone-datepicker>``. - -* Fix "view receiving row" page, per oruga. - -* Tweak styles for grid action links, per butterball. - - -0.10.1 (2024-04-28) -------------------- - -* Sort list of available themes. - -* Update various icon names for oruga compatibility. - -* Fix vertical alignment in main menu bar, for butterball. - -* Fix upgrade execution logic/UI per oruga. - -* Show "View This" button when cloning a record. - -* Stop including 'falafel' as available theme. - - -0.10.0 (2024-04-28) -------------------- - -This version bump is to reflect adding support for Vue 3 + Oruga via -the 'butterball' theme. There is likely more work to be done for that -yet, but it mostly works at this point. - -* Misc. template and view logic tweaks (applicable to all themes) for - better patterns, consistency etc. - -* Add initial support for Vue 3 + Oruga, via "butterball" theme. +NB. this file contains "old" release notes only. for newer releases +see the `CHANGELOG.md` file in the source root folder. 0.9.96 (2024-04-25) @@ -5177,7 +4992,7 @@ and related technologies. 0.6.47 (2017-11-08) ------------------- -* Fix manifest to include *.pt deform templates +* Fix manifest to include ``*.pt`` deform templates 0.6.46 (2017-11-08) @@ -5510,13 +5325,13 @@ and related technologies. 0.6.13 (2017-07-26) ------------------- +------------------- * Allow master view to decide whether each grid checkbox is checked 0.6.12 (2017-07-26) ------------------- +------------------- * Add basic support for product inventory and status @@ -5524,7 +5339,7 @@ and related technologies. 0.6.11 (2017-07-18) ------------------- +------------------- * Tweak some basic styles for forms/grids @@ -5532,7 +5347,7 @@ and related technologies. 0.6.10 (2017-07-18) ------------------- +------------------- * Fix grid bug if "current page" becomes invalid diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 00000000..bbf94f4b --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,8 @@ + +Changelog Archive +================= + +.. toctree:: + :maxdepth: 1 + + OLDCHANGES diff --git a/docs/index.rst b/docs/index.rst index 351e910d..db05d0c1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -60,6 +60,14 @@ Package API: api/views/purchasing.ordering +Changelog: + +.. toctree:: + :maxdepth: 1 + + changelog + + Documentation To-Do =================== diff --git a/pyproject.toml b/pyproject.toml index 7c894886..13a232ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.10.16" +version = "0.11.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -88,7 +88,7 @@ tailbone = "tailbone.config:ConfigExtension" Homepage = "https://rattailproject.org" 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/CHANGES.rst" +Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md" [tool.commitizen] From fb0c538a2bd0d58f85ea37c4d8524b4fcf8515a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 10 Jun 2024 17:42:29 -0500 Subject: [PATCH 012/211] test: skip running tests for py36 we should soon require python 3.8 anyway --- tox.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index ea833b39..6e45883c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,9 @@ [tox] -envlist = py36, py37, py38, py39, py310, py311 +# TODO: i had to remove py36 since something (hatchling?) broke it +# somehow, and i was not able to quickly fix. as of writing only +# one app is known to run py36 and hopefully that is not for long. +envlist = py37, py38, py39, py310, py311 # TODO: can remove this when we drop py36 support # nb. need this for testing older python versions From 6e741f6156a50442426f6a59f2321d11eedcbdf3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 14 Jun 2024 17:57:01 -0500 Subject: [PATCH 013/211] fix: revert back to setup.py + setup.cfg apparently with python 3.6 things "mostly" work but then they break if any specified dependencies have a dot in the name. which in this project, is the case for `zope.sqlalchemy` so until we drop python 3.6 support, we cannot use pyproject.toml here --- pyproject.toml | 101 ------------------------------------------------- setup.cfg | 97 +++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 3 ++ 3 files changed, 100 insertions(+), 101 deletions(-) delete mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 13a232ae..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,101 +0,0 @@ - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - - -[project] -name = "Tailbone" -version = "0.11.0" -description = "Backoffice Web Application for Rattail" -readme = "README.rst" -authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] -license = {text = "GNU GPL v3+"} -classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Web Environment", - "Framework :: Pyramid", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Office/Business", - "Topic :: Software Development :: Libraries :: Python Modules", -] - -dependencies = [ - "asgiref", - "colander", - "ColanderAlchemy", - "cornice", - "cornice-swagger", - "deform", - "humanize", - "Mako", - "markdown", - "openpyxl", - "paginate", - "paginate_sqlalchemy", - "passlib", - "Pillow", - "pyramid>=2", - "pyramid_beaker", - "pyramid_deform", - "pyramid_exclog", - "pyramid_fanstatic", - "pyramid_mako", - "pyramid_retry", - "pyramid_tm", - "rattail[db,bouncer]", - "six", - "sa-filters", - "simplejson", - "transaction", - "waitress", - "WebHelpers2", - "zope.sqlalchemy", -] - - -[project.optional-dependencies] -docs = ["Sphinx", "sphinx-rtd-theme"] -tests = ["coverage", "mock", "pytest", "pytest-cov"] - - -[project.entry-points."paste.app_factory"] -main = "tailbone.app:main" -webapi = "tailbone.webapi:main" - - -[project.entry-points."rattail.cleaners"] -beaker = "tailbone.cleanup:BeakerCleaner" - - -[project.entry-points."rattail.config.extensions"] -tailbone = "tailbone.config:ConfigExtension" - - -[project.urls] -Homepage = "https://rattailproject.org" -Repository = "https://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] -version_provider = "pep621" -tag_format = "v$version" -update_changelog_on_bump = true - - -# [tool.hatch.build.targets.wheel] -# packages = ["corepos"] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..83ce9814 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,97 @@ + +[metadata] +name = Tailbone +version = 0.11.0 +author = Lance Edgar +author_email = lance@edbob.org +url = http://rattailproject.org/ +license = GNU GPL v3 +description = Backoffice Web Application for Rattail +long_description = file: README.rst +classifiers = + Development Status :: 4 - Beta + Environment :: Web Environment + Framework :: Pyramid + Intended Audience :: Developers + License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) + Natural Language :: English + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Topic :: Internet :: WWW/HTTP + Topic :: Office/Business + Topic :: Software Development :: Libraries :: Python Modules + + +[options] +packages = find: +include_package_data = True +install_requires = + asgiref + colander + ColanderAlchemy + cornice + cornice-swagger + deform + humanize + Mako + markdown + openpyxl + paginate + paginate_sqlalchemy + passlib + Pillow + pyramid>=2 + pyramid_beaker + pyramid_deform + pyramid_exclog + pyramid_fanstatic + pyramid_mako + pyramid_retry + pyramid_tm + rattail[db,bouncer] + six + sa-filters + simplejson + transaction + waitress + WebHelpers2 + zope.sqlalchemy + + +[options.packages.find] +exclude = + tests.* + tests + + +[options.extras_require] +docs = Sphinx; sphinx-rtd-theme +tests = coverage; mock; pytest; pytest-cov + + +[options.entry_points] + +paste.app_factory = + main = tailbone.app:main + webapi = tailbone.webapi:main + +rattail.cleaners = + beaker = tailbone.cleanup:BeakerCleaner + +rattail.config.extensions = + tailbone = tailbone.config:ConfigExtension + + +[nosetests] +nocapture = 1 +cover-package = tailbone +cover-erase = 1 +cover-html = 1 +cover-html-dir = htmlcov diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..b908cbe5 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +import setuptools + +setuptools.setup() From ab4dbbedf05ffaf927d191fed670391f74a98eb1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 14 Jun 2024 18:01:40 -0500 Subject: [PATCH 014/211] =?UTF-8?q?bump:=20version=200.11.0=20=E2=86=92=20?= =?UTF-8?q?0.11.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ setup.cfg | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c51f3fda..40dfa16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.11.1 (2024-06-14) + +### Fix + +- revert back to setup.py + setup.cfg + ## v0.11.0 (2024-06-10) ### Feat diff --git a/setup.cfg b/setup.cfg index 83ce9814..2ea746e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.0 +version = 0.11.1 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From da4450b574cef8ec1b8cf77ac0c52085f395d5aa Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 14 Jun 2024 18:02:39 -0500 Subject: [PATCH 015/211] build: avoid version parse when uploading release --- tasks.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/tasks.py b/tasks.py index e9f47ccd..b57315a0 100644 --- a/tasks.py +++ b/tasks.py @@ -25,26 +25,11 @@ Tasks for Tailbone """ import os -import re import shutil from invoke import task -here = os.path.abspath(os.path.dirname(__file__)) -__version__ = None -pattern = re.compile(r'^version = "(\d+\.\d+\.\d+)"$') -with open(os.path.join(here, 'pyproject.toml'), 'rt') as f: - for line in f: - line = line.rstrip('\n') - match = pattern.match(line) - if match: - __version__ = match.group(1) - break -if not __version__: - raise RuntimeError("could not parse version!") - - @task def release(c, tests=False): """ @@ -53,7 +38,9 @@ def release(c, tests=False): if tests: c.run('tox') + if os.path.exists('dist'): + shutil.rmtree('dist') if os.path.exists('Tailbone.egg-info'): shutil.rmtree('Tailbone.egg-info') c.run('python -m build --sdist') - c.run(f'twine upload dist/tailbone-{__version__}.tar.gz') + c.run('twine upload dist/*') From 0212e52b6611b4906107217113f1fbe0e30d252d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 14 Jun 2024 19:59:52 -0500 Subject: [PATCH 016/211] fix: hide certain custorder settings if not applicable --- tailbone/templates/custorders/configure.mako | 51 ++++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/tailbone/templates/custorders/configure.mako b/tailbone/templates/custorders/configure.mako index d2f6610d..16d26d21 100644 --- a/tailbone/templates/custorders/configure.mako +++ b/tailbone/templates/custorders/configure.mako @@ -24,29 +24,38 @@ </b-checkbox> </b-field> - <b-field message="Only applies if user is allowed to choose contact info."> - <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" - v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" - native-value="true" - @input="settingsNeedSaved = true"> - Allow user to enter new contact info - </b-checkbox> - </b-field> + <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_choice']" + style="padding-left: 2rem;"> - <p class="block"> - If you allow users to enter new contact info, the default action - when the order is submitted, is to send email with details of - the new contact info. Settings for these are at: - </p> + <b-field message="Only applies if user is allowed to choose contact info."> + <b-checkbox name="rattail.custorders.new_orders.allow_contact_info_create" + v-model="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + native-value="true" + @input="settingsNeedSaved = true"> + Allow user to enter new contact info + </b-checkbox> + </b-field> - <ul class="list"> - <li class="list-item"> - ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))} - </li> - <li class="list-item"> - ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} - </li> - </ul> + <div v-show="simpleSettings['rattail.custorders.new_orders.allow_contact_info_create']" + style="padding-left: 2rem;"> + + <p class="block"> + If you allow users to enter new contact info, the default action + when the order is submitted, is to send email with details of + the new contact info. Settings for these are at: + </p> + + <ul class="list"> + <li class="list-item"> + ${h.link_to("New Phone Request", url('emailprofiles.view', key='new_phone_requested'))} + </li> + <li class="list-item"> + ${h.link_to("New Email Request", url('emailprofiles.view', key='new_email_requested'))} + </li> + </ul> + + </div> + </div> </div> <h3 class="block is-size-3">Product Handling</h3> From 88e7d86087c590d3ae3bc957dc1685ca7b815414 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 18 Jun 2024 15:04:05 -0500 Subject: [PATCH 017/211] fix: use different logic for buefy/oruga for product lookup keydown i could have swore the new logic worked with buefy..but today it didn't --- tailbone/templates/products/lookup.mako | 28 +++++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/tailbone/templates/products/lookup.mako b/tailbone/templates/products/lookup.mako index 7997eb7d..bb9590b2 100644 --- a/tailbone/templates/products/lookup.mako +++ b/tailbone/templates/products/lookup.mako @@ -56,7 +56,11 @@ <b-field grouped> <b-input v-model="searchTerm" - ref="searchTermInput" /> + ref="searchTermInput" + % if not request.use_oruga: + @keydown.native="searchTermInputKeydown" + % endif + /> <b-button class="control" type="is-primary" @@ -243,8 +247,10 @@ lookupShowDialog: false, searchTerm: null, - searchTermInputElement: null, searchTermLastUsed: null, + % if request.use_oruga: + searchTermInputElement: null, + % endif searchProductKey: true, searchVendorItemCode: true, @@ -259,14 +265,18 @@ } }, - mounted() { - this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input') - this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown) - }, + % if request.use_oruga: - beforeDestroy() { - this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown) - }, + mounted() { + this.searchTermInputElement = this.$refs.searchTermInput.$el.querySelector('input') + this.searchTermInputElement.addEventListener('keydown', this.searchTermInputKeydown) + }, + + beforeDestroy() { + this.searchTermInputElement.removeEventListener('keydown', this.searchTermInputKeydown) + }, + + % endif methods: { From 231ca0363acf680a3538ad10b289d8ad9148666d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 18 Jun 2024 16:06:55 -0500 Subject: [PATCH 018/211] fix: product records should be touchable --- tailbone/views/products.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 28186ac3..5265edbc 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -81,6 +81,7 @@ class ProductView(MasterView): supports_autocomplete = True bulk_deletable = True mergeable = True + touchable = True configurable = True labels = { From a0cd8835e038f4952824da17f37172d5fe9fe334 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 18 Jun 2024 16:07:07 -0500 Subject: [PATCH 019/211] fix: show flash error message if resolve pending product fails --- tailbone/views/products.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 5265edbc..c395ff24 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2563,7 +2563,14 @@ class PendingProductView(MasterView): app = self.get_rattail_app() products_handler = app.get_products_handler() kwargs = self.get_resolve_product_kwargs() - products_handler.resolve_product(pending, product, self.request.user, **kwargs) + + try: + products_handler.resolve_product(pending, product, self.request.user, **kwargs) + except Exception as error: + log.warning("failed to resolve product", exc_info=True) + self.request.session.flash(f"Resolve failed: {simple_error(error)}", 'error') + return redirect + return redirect def get_resolve_product_kwargs(self, **kwargs): From 525a28f3fe7b0e1b6e21576f06bd0cc341252ff7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 18 Jun 2024 18:05:05 -0500 Subject: [PATCH 020/211] =?UTF-8?q?bump:=20version=200.11.1=20=E2=86=92=20?= =?UTF-8?q?0.11.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ setup.cfg | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40dfa16e..ed866741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.11.2 (2024-06-18) + +### Fix + +- hide certain custorder settings if not applicable + +- use different logic for buefy/oruga for product lookup keydown + +- product records should be touchable + +- show flash error message if resolve pending product fails + ## v0.11.1 (2024-06-14) ### Fix diff --git a/setup.cfg b/setup.cfg index 2ea746e9..aa14088a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.1 +version = 0.11.2 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 067ca5bd4354f8dd47f5a3e9206627e3c6f6ae32 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 27 Jun 2024 23:11:13 -0500 Subject: [PATCH 021/211] fix: add link to "resolved by" user for pending products --- tailbone/views/products.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index c395ff24..bf2d7f14 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -2457,9 +2457,10 @@ class PendingProductView(MasterView): # resolved* if self.creating: f.remove('resolved', 'resolved_by') + elif pending.resolved: + f.set_renderer('resolved_by', self.render_user) else: - if not pending.resolved: - f.remove('resolved', 'resolved_by') + f.remove('resolved', 'resolved_by') def render_status_code(self, pending, field): status = pending.status_code From 3b7cc19faa758e83cb6f358e4bcb93fc3f15c06e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Jun 2024 15:36:08 -0500 Subject: [PATCH 022/211] fix: handle error when merging 2 records fails should give the user some idea of the problem instead of just sending error email to admins --- tailbone/views/master.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 48bc32fe..1e917902 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2292,9 +2292,13 @@ class MasterView(View): except Exception as error: self.request.session.flash("Requested merge cannot proceed (maybe swap kept/removed and try again?): {}".format(error), 'error') else: - self.merge_objects(object_to_remove, object_to_keep) - self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep)) - return self.redirect(self.get_action_url('view', object_to_keep)) + try: + self.merge_objects(object_to_remove, object_to_keep) + self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep)) + return self.redirect(self.get_action_url('view', object_to_keep)) + except Exception as error: + error = simple_error(error) + self.request.session.flash(f"merge failed: {error}", 'error') if not object_to_remove or not object_to_keep or object_to_remove is object_to_keep: return self.redirect(self.get_index_url()) From d17bd35909444f30807b486a3b0eded5bb4915b4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Jun 2024 15:39:59 -0500 Subject: [PATCH 023/211] =?UTF-8?q?bump:=20version=200.11.2=20=E2=86=92=20?= =?UTF-8?q?0.11.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ setup.cfg | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed866741..f18a87ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.11.3 (2024-06-28) + +### Fix + +- add link to "resolved by" user for pending products + +- handle error when merging 2 records fails + ## v0.11.2 (2024-06-18) ### Fix diff --git a/setup.cfg b/setup.cfg index aa14088a..2dd65a74 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.2 +version = 0.11.3 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From ec5ed490d91438b679315ee88cfeb37c2368ac10 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Jun 2024 17:34:54 -0500 Subject: [PATCH 024/211] fix: start/stop being root should submit POST instead of GET obviously it's access-restricted anyway but this just seems more correct but more importantly this makes the referrer explicit, since for some unknown reason i am suddenly seeing that be blank for certain installs where that wasn't the case before (?) - and the result was that every time you start/stop being root you would be redirected to home page instead of remaining on current page --- .../templates/themes/butterball/base.mako | 30 +++++++++++++++++-- tailbone/views/auth.py | 3 ++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 3f0253ce..339d23bd 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -924,9 +924,23 @@ % endif <div class="navbar-dropdown"> % if request.is_root: - ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item has-background-danger has-text-white')} + ${h.form(url('stop_root'), ref='stopBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="stopBeingRoot()" + class="navbar-item has-background-danger has-text-white"> + Stop being root + </a> + ${h.end_form()} % elif request.is_admin: - ${h.link_to("Become root", url('become_root'), class_='navbar-item has-background-danger has-text-white')} + ${h.form(url('become_root'), ref='startBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="startBeingRoot()" + class="navbar-item has-background-danger has-text-white"> + Become root + </a> + ${h.end_form()} % endif % if messaging_enabled: ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} @@ -1109,6 +1123,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 }, } diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 7ecdc6cd..730d7b6a 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -238,6 +238,9 @@ class AuthenticationView(View): config.add_view(cls, attr='change_password', route_name='change_password', renderer='/change_password.mako') # become/stop root + # TODO: these should require POST but i won't bother until + # after butterball becomes default theme..or probably should + # just refactor the falafel theme accordingly..? config.add_route('become_root', '/root/yes') config.add_view(cls, attr='become_root', route_name='become_root') config.add_route('stop_root', '/root/no') From 9b6447c4cb1c5a51486436ee8ddb4ec675c6fb7d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 28 Jun 2024 17:58:27 -0500 Subject: [PATCH 025/211] fix: require vendor when making new ordering batch via api pretty sure this pattern needs to be expanded and probably improved, but wanted to fix this one scenario for now, per error email --- tailbone/api/batch/ordering.py | 2 ++ tailbone/api/master.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index 1b11194e..204be8ad 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -86,6 +86,8 @@ class OrderingBatchViews(APIBatchView): Sets the mode to "ordering" for the new batch. """ data = dict(data) + if not data.get('vendor_uuid'): + raise ValueError("You must specify the vendor") data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING batch = super().create_object(data) return batch diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 70616484..2d17339e 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -31,7 +31,7 @@ from rattail.db.util import get_fieldnames from cornice import resource, Service -from tailbone.api import APIView, api +from tailbone.api import APIView from tailbone.db import Session from tailbone.util import SortColumn @@ -355,9 +355,13 @@ class APIMasterView(APIView): data = self.request.json_body # add instance to session, and return data for it - obj = self.create_object(data) - self.Session.flush() - return self._get(obj) + try: + obj = self.create_object(data) + except Exception as error: + return self.json_response({'error': str(error)}) + else: + self.Session.flush() + return self._get(obj) def create_object(self, data): """ From 83e4d95741e098a065a41d81f0776232b8008583 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 10:32:05 -0500 Subject: [PATCH 026/211] fix: don't escape each address for email attempts grid now that we are properly escaping the full cell value, no need --- tailbone/views/email.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index 22954782..4014c05e 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -28,14 +28,13 @@ import logging import re import warnings -from rattail import mail -from rattail.db import model -from rattail.config import parse_list +from wuttjamaican.util import parse_list + +from rattail.db.model import EmailAttempt from rattail.util import simple_error import colander from deform import widget as dfwidget -from webhelpers2.html import HTML from tailbone import grids from tailbone.db import Session @@ -85,7 +84,7 @@ class EmailSettingView(MasterView): ] def __init__(self, request): - super(EmailSettingView, self).__init__(request) + super().__init__(request) self.email_handler = self.get_handler() @property @@ -204,7 +203,7 @@ class EmailSettingView(MasterView): return True def configure_form(self, f): - super(EmailSettingView, self).configure_form(f) + super().configure_form(f) profile = f.model_instance['_email'] # key @@ -437,7 +436,7 @@ class EmailPreview(View): """ def __init__(self, request): - super(EmailPreview, self).__init__(request) + super().__init__(request) if hasattr(self, 'get_handler'): warnings.warn("defining a get_handler() method is deprecated; " @@ -520,7 +519,7 @@ class EmailAttemptView(MasterView): """ Master view for email attempts. """ - model_class = model.EmailAttempt + model_class = EmailAttempt route_prefix = 'email_attempts' url_prefix = '/email/attempts' creatable = False @@ -553,7 +552,7 @@ class EmailAttemptView(MasterView): ] def configure_grid(self, g): - super(EmailAttemptView, self).configure_grid(g) + super().configure_grid(g) # sent g.set_sort_defaults('sent', 'desc') @@ -583,13 +582,12 @@ class EmailAttemptView(MasterView): if len(recips) > 2: recips = recips[:2] recips.append('...') - recips = [HTML.escape(r) for r in recips] return ', '.join(recips) return value def configure_form(self, f): - super(EmailAttemptView, self).configure_form(f) + super().configure_form(f) # key f.set_renderer('key', self.render_email_key) From eff5341335a898c6770d6a28dd7dde77b2bdad20 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 10:49:54 -0500 Subject: [PATCH 027/211] =?UTF-8?q?bump:=20version=200.11.3=20=E2=86=92=20?= =?UTF-8?q?0.11.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ setup.cfg | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f18a87ea..8d92a99e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.11.4 (2024-06-30) + +### Fix + +- start/stop being root should submit POST instead of GET + +- require vendor when making new ordering batch via api + +- don't escape each address for email attempts grid + ## v0.11.3 (2024-06-28) ### Fix diff --git a/setup.cfg b/setup.cfg index 2dd65a74..82cf3b25 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.3 +version = 0.11.4 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 1dc632174eed4058f07c75ede528e0b7ec0188a9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 11:44:33 -0500 Subject: [PATCH 028/211] fix: allow comma in numeric filter input just remove them and run with the remainder, on the SQL side --- tailbone/grids/filters.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 3b198614..7e52bb8d 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -26,6 +26,7 @@ Grid Filters import re import datetime +import decimal import logging from collections import OrderedDict @@ -647,12 +648,22 @@ class AlchemyNumericFilter(AlchemyGridFilter): # first just make sure it's somewhat numeric try: - float(value) - except ValueError: + self.parse_decimal(value) + except decimal.InvalidOperation: return True return bool(value and len(str(value)) > 8) + def parse_decimal(self, value): + if value: + value = value.replace(',', '') + return decimal.Decimal(value) + + def encode_value(self, value): + if value: + value = str(self.parse_decimal(value)) + return super().encode_value(value) + def filter_equal(self, query, value): if self.value_invalid(value): return query From 3f7de5872e50f4ffd6e8c510ed8738bd24e0b870 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 12:40:03 -0500 Subject: [PATCH 029/211] fix: add custom url prefix if needed, for fanstatic --- tailbone/util.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/util.py b/tailbone/util.py index 9a993176..78c41313 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -201,6 +201,9 @@ def get_liburl(request, key, fallback=True): 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) From d6939e52b48bd5d6b947deb67a241782c321b7f0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 18:25:01 -0500 Subject: [PATCH 030/211] fix: use vue 3.4.31 and oruga 0.8.12 by default i.e. for butterball theme cf. https://github.com/oruga-ui/oruga/issues/974#issuecomment-2198573369 --- tailbone/util.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index 78c41313..98a7f7d4 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -162,11 +162,10 @@ def get_libver(request, key, fallback=True, default_only=False): return '5.3.1' elif key == 'bb_vue': - # TODO: iiuc vue 3.4 does not work with oruga yet - return '3.3.11' + return '3.4.31' elif key == 'bb_oruga': - return '0.8.9' + return '0.8.12' elif key in ('bb_oruga_bulma', 'bb_oruga_bulma_css'): return '0.3.0' From cad50c9149143eff8c6329e77d3c20015d0f0331 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 30 Jun 2024 21:28:56 -0500 Subject: [PATCH 031/211] =?UTF-8?q?bump:=20version=200.11.4=20=E2=86=92=20?= =?UTF-8?q?0.11.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ setup.cfg | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d92a99e..510aa6a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.11.5 (2024-06-30) + +### Fix + +- allow comma in numeric filter input + +- add custom url prefix if needed, for fanstatic + +- use vue 3.4.31 and oruga 0.8.12 by default + ## v0.11.4 (2024-06-30) ### Fix diff --git a/setup.cfg b/setup.cfg index 82cf3b25..4ee92f01 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.4 +version = 0.11.5 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 6f8b825b0b24370af4a66cac02e4faa2eb43fee1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Jul 2024 15:23:56 -0500 Subject: [PATCH 032/211] fix: set explicit referrer when changing dbkey since for some reason HTTP_REFERER is not always set now?? --- tailbone/templates/themes/butterball/base.mako | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 339d23bd..e38696c5 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -747,6 +747,7 @@ ${h.form(url('change_db_engine'), ref='dbPickerForm')} ${h.csrf_token(request)} ${h.hidden('engine_type', value=master.engine_type_key)} + <input type="hidden" name="referrer" :value="referrer" /> <b-select name="dbkey" v-model="dbSelected" @input="changeDB()"> From 2feb07e1d3488a798028be3ab7cc63b3ef40de1c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Jul 2024 17:01:01 -0500 Subject: [PATCH 033/211] fix: remove references, dependency for `six` package --- setup.cfg | 1 - tailbone/api/batch/labels.py | 10 ++--- tailbone/api/customers.py | 8 +--- tailbone/api/people.py | 8 +--- tailbone/api/upgrades.py | 8 +--- tailbone/api/vendors.py | 8 +--- tailbone/api/workorders.py | 20 ++++------ tailbone/exceptions.py | 7 +--- tailbone/handler.py | 9 ++--- tailbone/subscribers.py | 2 - tailbone/templates/base.mako | 2 +- tailbone/templates/configure.mako | 2 +- tailbone/templates/custorders/items/view.mako | 2 +- tailbone/templates/generate_feature.mako | 2 +- tailbone/templates/ordering/worksheet.mako | 4 +- tailbone/templates/poser/views/configure.mako | 2 +- tailbone/templates/products/batch.mako | 2 +- tailbone/templates/shifts/base.mako | 4 +- .../templates/themes/butterball/base.mako | 2 +- .../trainwreck/transactions/configure.mako | 2 +- .../trainwreck/transactions/rollover.mako | 2 +- tailbone/tweens.py | 7 +--- tailbone/views/batch/labels.py | 16 +++----- tailbone/views/batch/pricing.py | 24 +++++------ tailbone/views/batch/vendorinvoice.py | 16 +++----- tailbone/views/exports.py | 24 ++++------- tailbone/views/poser/reports.py | 14 +++---- tailbone/views/poser/views.py | 40 +++++++++---------- tailbone/views/progress.py | 8 +--- tailbone/views/tempmon/appliances.py | 16 ++++---- tailbone/views/vendors/core.py | 14 +++---- 31 files changed, 105 insertions(+), 181 deletions(-) diff --git a/setup.cfg b/setup.cfg index 4ee92f01..8afd9be4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,7 +56,6 @@ install_requires = pyramid_retry pyramid_tm rattail[db,bouncer] - six sa-filters simplejson transaction diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py index 4787aeb9..4f154b21 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Label Batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api.batch import APIBatchView, APIBatchRowView @@ -56,10 +52,10 @@ class LabelBatchRowViews(APIBatchRowView): def normalize(self, row): batch = row.batch - data = super(LabelBatchRowViews, self).normalize(row) + data = super().normalize(row) data['item_id'] = row.item_id - data['upc'] = six.text_type(row.upc) + data['upc'] = str(row.upc) data['upc_pretty'] = row.upc.pretty() if row.upc else None data['brand_name'] = row.brand_name data['description'] = row.description diff --git a/tailbone/api/customers.py b/tailbone/api/customers.py index e9953572..85d28c24 100644 --- a/tailbone/api/customers.py +++ b/tailbone/api/customers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Customer Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -46,7 +42,7 @@ class CustomerView(APIMasterView): def normalize(self, customer): return { 'uuid': customer.uuid, - '_str': six.text_type(customer), + '_str': str(customer), 'id': customer.id, 'number': customer.number, 'name': customer.name, diff --git a/tailbone/api/people.py b/tailbone/api/people.py index 7e06e969..f7c08dfa 100644 --- a/tailbone/api/people.py +++ b/tailbone/api/people.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Person Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -45,7 +41,7 @@ class PersonView(APIMasterView): def normalize(self, person): return { 'uuid': person.uuid, - '_str': six.text_type(person), + '_str': str(person), 'first_name': person.first_name, 'last_name': person.last_name, 'display_name': person.display_name, diff --git a/tailbone/api/upgrades.py b/tailbone/api/upgrades.py index 6ce5f778..467c8a0d 100644 --- a/tailbone/api/upgrades.py +++ b/tailbone/api/upgrades.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Upgrade Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -53,7 +49,7 @@ class UpgradeView(APIMasterView): data['status_code'] = None else: data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code, - six.text_type(upgrade.status_code)) + str(upgrade.status_code)) return data diff --git a/tailbone/api/vendors.py b/tailbone/api/vendors.py index 7fa61590..64311b1b 100644 --- a/tailbone/api/vendors.py +++ b/tailbone/api/vendors.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Web API - Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from tailbone.api import APIMasterView @@ -44,7 +40,7 @@ class VendorView(APIMasterView): def normalize(self, vendor): return { 'uuid': vendor.uuid, - '_str': six.text_type(vendor), + '_str': str(vendor), 'id': vendor.id, 'name': vendor.name, } diff --git a/tailbone/api/workorders.py b/tailbone/api/workorders.py index eabe4cdb..19def6c4 100644 --- a/tailbone/api/workorders.py +++ b/tailbone/api/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Tailbone Web API - Work Order Views """ -from __future__ import unicode_literals, absolute_import - import datetime -import six - from rattail.db.model import WorkOrder from cornice import Service @@ -44,19 +40,19 @@ class WorkOrderView(APIMasterView): object_url_prefix = '/workorder' def __init__(self, *args, **kwargs): - super(WorkOrderView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def normalize(self, workorder): - data = super(WorkOrderView, self).normalize(workorder) + data = super().normalize(workorder) data.update({ 'customer_name': workorder.customer.name, 'status_label': self.enum.WORKORDER_STATUS[workorder.status_code], - 'date_submitted': six.text_type(workorder.date_submitted or ''), - 'date_received': six.text_type(workorder.date_received or ''), - 'date_released': six.text_type(workorder.date_released or ''), - 'date_delivered': six.text_type(workorder.date_delivered or ''), + 'date_submitted': str(workorder.date_submitted or ''), + 'date_received': str(workorder.date_received or ''), + 'date_released': str(workorder.date_released or ''), + 'date_delivered': str(workorder.date_delivered or ''), }) return data @@ -87,7 +83,7 @@ class WorkOrderView(APIMasterView): if 'status_code' in data: data['status_code'] = int(data['status_code']) - return super(WorkOrderView, self).update_object(workorder, data) + return super().update_object(workorder, data) def status_codes(self): """ diff --git a/tailbone/exceptions.py b/tailbone/exceptions.py index beea1366..3468562a 100644 --- a/tailbone/exceptions.py +++ b/tailbone/exceptions.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Tailbone Exceptions """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.exceptions import RattailError @@ -37,7 +33,6 @@ class TailboneError(RattailError): """ -@six.python_2_unicode_compatible class TailboneJSONFieldError(TailboneError): """ Error raised when JSON serialization of a form field results in an error. diff --git a/tailbone/handler.py b/tailbone/handler.py index db95bc71..22f33cca 100644 --- a/tailbone/handler.py +++ b/tailbone/handler.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Tailbone Handler """ -from __future__ import unicode_literals, absolute_import - -import six from mako.lookup import TemplateLookup from rattail.app import GenericHandler @@ -41,7 +38,7 @@ class TailboneHandler(GenericHandler): """ def __init__(self, *args, **kwargs): - super(TailboneHandler, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # TODO: make templates dir configurable? templates = [resource_path('rattail:templates/web')] @@ -67,7 +64,7 @@ class TailboneHandler(GenericHandler): Returns an iterator over all registered Tailbone providers. """ providers = get_all_providers(self.config) - return six.itervalues(providers) + return providers.values() def write_model_view(self, data, path, **kwargs): """ diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 3fcd1017..bd59a033 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -24,7 +24,6 @@ Event Subscribers """ -import six import json import datetime import logging @@ -177,7 +176,6 @@ def before_render(event): renderer_globals['tailbone'] = tailbone renderer_globals['model'] = request.rattail_config.get_model() renderer_globals['enum'] = request.rattail_config.get_enum() - renderer_globals['six'] = six renderer_globals['json'] = json renderer_globals['datetime'] = datetime renderer_globals['colander'] = colander diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index f576473d..c4cbd648 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -890,7 +890,7 @@ % if request.user: FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(six.text_type(request.user))|n} + FeedbackFormData.userName = ${json.dumps(str(request.user))|n} % endif </script> diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 3aa60f31..f33779c8 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -236,7 +236,7 @@ % if input_file_template_settings is not Undefined: ThisPage.methods.validateInputFileTemplateSettings = function() { - % for tmpl in six.itervalues(input_file_templates): + % 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']}']) { diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 41567d41..f7a6dd0a 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -347,7 +347,7 @@ } ThisPageData.orderItemStatuses = ${json.dumps(enum.CUSTORDER_ITEM_STATUS)|n} - ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in six.iteritems(enum.CUSTORDER_ITEM_STATUS)])|n} + ThisPageData.orderItemStatusOptions = ${json.dumps([dict(key=k, label=v) for k, v in enum.CUSTORDER_ITEM_STATUS.items()])|n} ThisPageData.oldStatusCode = ${instance.status_code} diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index a7064331..18a26f58 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -296,7 +296,7 @@ % endfor } - % for key, form in six.iteritems(feature_forms): + % for key, form in feature_forms.items(): <% safekey = key.replace('-', '_') %> ThisPageData.${safekey} = { <% dform = feature_forms[key].make_deform_form() %> diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index e41fe15f..ca1abf6e 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -73,7 +73,7 @@ <div class="grid"> <table class="order-form"> <% column_count = 8 + len(header_columns) + (0 if ignore_cases else 1) + int(capture(self.extra_count)) %> - % for department in sorted(six.itervalues(departments), key=lambda d: d.name if d else ''): + % for department in sorted(departments.values(), key=lambda d: d.name if d else ''): <thead> <tr> <th class="department" colspan="${column_count}">Department @@ -84,7 +84,7 @@ % endif </th> </tr> - % for subdepartment in sorted(six.itervalues(department._order_subdepartments), key=lambda s: s.name if s else ''): + % for subdepartment in sorted(department._order_subdepartments.values(), key=lambda s: s.name if s else ''): <tr> <th class="subdepartment" colspan="${column_count}">Subdepartment % if subdepartment.number or subdepartment.name: diff --git a/tailbone/templates/poser/views/configure.mako b/tailbone/templates/poser/views/configure.mako index f4d75779..cdde15c5 100644 --- a/tailbone/templates/poser/views/configure.mako +++ b/tailbone/templates/poser/views/configure.mako @@ -9,7 +9,7 @@ % for topkey, topgroup in sorted(view_settings.items(), key=lambda itm: 'aaaa' if itm[0] == 'rattail' else itm[0]): <h3 class="block is-size-3">Views for: ${topkey}</h3> - % for group_key, group in six.iteritems(topgroup): + % for group_key, group in topgroup.items(): <h4 class="block is-size-4">${group_key.capitalize()}</h4> % for key, label in group: ${self.simple_flag(key, label)} diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index e0b93bd6..a4a4d503 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -30,7 +30,7 @@ ${render_deform_field(form, dform['description'])} ${render_deform_field(form, dform['notes'])} - % for key, pform in six.iteritems(params_forms): + % for key, pform in params_forms.items(): <div v-show="field_model_batch_type == '${key}'"> % for field in pform.make_deform_form(): ${render_deform_field(pform, field)} diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index 4bae5ebf..52b48832 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -57,7 +57,7 @@ <div class="field-wrapper employee"> <label>Employee</label> <div class="field"> - ${dform['employee'].serialize(text=six.text_type(employee), selected_callback='employee_selected')|n} + ${dform['employee'].serialize(text=str(employee), selected_callback='employee_selected')|n} </div> </div> % endif @@ -152,7 +152,7 @@ </tr> </thead> <tbody> - % for emp in sorted(employees, key=six.text_type): + % for emp in sorted(employees, key=str): <tr data-employee-uuid="${emp.uuid}"> <td class="employee"> ## TODO: add link to single employee schedule / timesheet here... diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index e38696c5..b0e43a37 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -421,7 +421,7 @@ referrer: null, % if request.user: userUUID: ${json.dumps(request.user.uuid)|n}, - userName: ${json.dumps(six.text_type(request.user))|n}, + userName: ${json.dumps(str(request.user))|n}, % else: userUUID: null, userName: null, diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako index fd6c53a7..99b43fde 100644 --- a/tailbone/templates/trainwreck/transactions/configure.mako +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -33,7 +33,7 @@ The selected DBs will be hidden from the DB picker when viewing Trainwreck data. </p> - % for key, engine in six.iteritems(trainwreck_engines): + % for key, engine in trainwreck_engines.items(): <b-field> <b-checkbox name="hidedb_${key}" v-model="hiddenDatabases['${key}']" diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako index 8e27d087..b36e7bc3 100644 --- a/tailbone/templates/trainwreck/transactions/rollover.mako +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -8,7 +8,7 @@ <%def name="page_content()"> <br /> - % if six.text_type(next_year) not in trainwreck_engines: + % if str(next_year) not in trainwreck_engines: <b-notification type="is-warning"> You do not have a database configured for next year (${next_year}). You should be sure to configure it before next year rolls around. diff --git a/tailbone/tweens.py b/tailbone/tweens.py index f944a66f..9c06c1be 100644 --- a/tailbone/tweens.py +++ b/tailbone/tweens.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Tween Factories """ -from __future__ import unicode_literals, absolute_import - -import six from sqlalchemy.exc import OperationalError @@ -64,7 +61,7 @@ def sqlerror_tween_factory(handler, registry): mark_error_retryable(error) raise error else: - raise TransientError(six.text_type(error)) + raise TransientError(str(error)) # if connection was *not* invalid, raise original error raise diff --git a/tailbone/views/batch/labels.py b/tailbone/views/batch/labels.py index 79b14a76..7291b05e 100644 --- a/tailbone/views/batch/labels.py +++ b/tailbone/views/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for label batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from deform import widget as dfwidget @@ -123,7 +119,7 @@ class LabelBatchView(BatchMasterView): ] def configure_form(self, f): - super(LabelBatchView, self).configure_form(f) + super().configure_form(f) # handheld_batches if self.creating: @@ -142,7 +138,7 @@ class LabelBatchView(BatchMasterView): f.replace('label_profile', 'label_profile_uuid') # TODO: should restrict somehow? just allow override? profiles = self.Session.query(model.LabelProfile) - values = [(p.uuid, six.text_type(p)) + values = [(p.uuid, str(p)) for p in profiles] require_profile = False if not require_profile: @@ -159,7 +155,7 @@ class LabelBatchView(BatchMasterView): return HTML.tag('ul', c=items) def configure_row_grid(self, g): - super(LabelBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) # short labels g.set_label('brand_name', "Brand") @@ -171,7 +167,7 @@ class LabelBatchView(BatchMasterView): return 'warning' def configure_row_form(self, f): - super(LabelBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('sequence') @@ -219,7 +215,7 @@ class LabelBatchView(BatchMasterView): profiles = self.Session.query(model.LabelProfile)\ .filter(model.LabelProfile.visible == True)\ .order_by(model.LabelProfile.ordinal) - profile_values = [(p.uuid, six.text_type(p)) + profile_values = [(p.uuid, str(p)) for p in profiles] f.set_widget('label_profile_uuid', forms.widgets.JQuerySelectWidget(values=profile_values)) diff --git a/tailbone/views/batch/pricing.py b/tailbone/views/batch/pricing.py index 6ba28889..5b5d013b 100644 --- a/tailbone/views/batch/pricing.py +++ b/tailbone/views/batch/pricing.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for pricing batches """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from rattail.time import localtime @@ -155,7 +151,7 @@ class PricingBatchView(BatchMasterView): return self.batch_handler.allow_future() def configure_form(self, f): - super(PricingBatchView, self).configure_form(f) + super().configure_form(f) app = self.get_rattail_app() batch = f.model_instance @@ -192,7 +188,7 @@ class PricingBatchView(BatchMasterView): f.set_required('input_filename', False) def get_batch_kwargs(self, batch, **kwargs): - kwargs = super(PricingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs['start_date'] = batch.start_date kwargs['min_diff_threshold'] = batch.min_diff_threshold kwargs['min_diff_percent'] = batch.min_diff_percent @@ -213,7 +209,7 @@ class PricingBatchView(BatchMasterView): return kwargs def configure_row_grid(self, g): - super(PricingBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_joiner('vendor_id', lambda q: q.outerjoin(model.Vendor)) g.set_sorter('vendor_id', model.Vendor.id) @@ -241,13 +237,13 @@ class PricingBatchView(BatchMasterView): if row.subdepartment_number: if row.subdepartment_name: return HTML.tag('span', title=row.subdepartment_name, - c=six.text_type(row.subdepartment_number)) + c=str(row.subdepartment_number)) return row.subdepartment_number def render_true_margin(self, row, field): margin = row.true_margin if margin: - margin = six.text_type(margin) + margin = str(margin) else: margin = HTML.literal(' ') if row.old_true_margin is not None: @@ -295,7 +291,7 @@ class PricingBatchView(BatchMasterView): return HTML.tag('span', title=title, c=text) def configure_row_form(self, f): - super(PricingBatchView, self).configure_row_form(f) + super().configure_row_form(f) # readonly fields f.set_readonly('product') @@ -328,7 +324,7 @@ class PricingBatchView(BatchMasterView): return tags.link_to(text, url) def get_row_csv_fields(self): - fields = super(PricingBatchView, self).get_row_csv_fields() + fields = super().get_row_csv_fields() if 'vendor_uuid' in fields: i = fields.index('vendor_uuid') @@ -344,7 +340,7 @@ class PricingBatchView(BatchMasterView): # TODO: this is the same as xlsx row! should merge/share somehow? def get_row_csv_row(self, row, fields): - csvrow = super(PricingBatchView, self).get_row_csv_row(row, fields) + csvrow = super().get_row_csv_row(row, fields) vendor = row.vendor if 'vendor_id' in fields: @@ -358,7 +354,7 @@ class PricingBatchView(BatchMasterView): # TODO: this is the same as csv row! should merge/share somehow? def get_row_xlsx_row(self, row, fields): - xlrow = super(PricingBatchView, self).get_row_xlsx_row(row, fields) + xlrow = super().get_row_xlsx_row(row, fields) vendor = row.vendor if 'vendor_id' in fields: diff --git a/tailbone/views/batch/vendorinvoice.py b/tailbone/views/batch/vendorinvoice.py index 6b8bdef7..4815d1f4 100644 --- a/tailbone/views/batch/vendorinvoice.py +++ b/tailbone/views/batch/vendorinvoice.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Views for maintaining vendor invoices """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser @@ -89,10 +85,10 @@ class VendorInvoiceView(FileBatchMasterView): ] def get_instance_title(self, batch): - return six.text_type(batch.vendor) + return str(batch.vendor) def configure_grid(self, g): - super(VendorInvoiceView, self).configure_grid(g) + super().configure_grid(g) # vendor g.set_joiner('vendor', lambda q: q.join(model.Vendor)) @@ -118,7 +114,7 @@ class VendorInvoiceView(FileBatchMasterView): g.set_link('executed', False) def configure_form(self, f): - super(VendorInvoiceView, self).configure_form(f) + super().configure_form(f) # vendor if self.creating: @@ -167,7 +163,7 @@ class VendorInvoiceView(FileBatchMasterView): # raise formalchemy.ValidationError(unicode(error)) def get_batch_kwargs(self, batch): - kwargs = super(VendorInvoiceView, self).get_batch_kwargs(batch) + kwargs = super().get_batch_kwargs(batch) kwargs['parser_key'] = batch.parser_key return kwargs @@ -183,7 +179,7 @@ class VendorInvoiceView(FileBatchMasterView): return True def configure_row_grid(self, g): - super(VendorInvoiceView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_label('upc', "UPC") g.set_label('brand_name', "Brand") g.set_label('shipped_cases', "Cases") diff --git a/tailbone/views/exports.py b/tailbone/views/exports.py index 82591099..44df359f 100644 --- a/tailbone/views/exports.py +++ b/tailbone/views/exports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,13 +24,9 @@ Master class for generic export history views """ -from __future__ import unicode_literals, absolute_import - import os import shutil -import six - from pyramid.response import FileResponse from webhelpers2.html import tags @@ -83,7 +79,7 @@ class ExportMasterView(MasterView): return self.get_file_path(export) def configure_grid(self, g): - super(ExportMasterView, self).configure_grid(g) + super().configure_grid(g) model = self.model # id @@ -106,7 +102,7 @@ class ExportMasterView(MasterView): return export.id_str def configure_form(self, f): - super(ExportMasterView, self).configure_form(f) + super().configure_form(f) export = f.model_instance # NOTE: we try to handle the 'creating' scenario even though this class @@ -149,7 +145,7 @@ class ExportMasterView(MasterView): f.set_renderer('filename', self.render_downloadable_file) def objectify(self, form, data=None): - obj = super(ExportMasterView, self).objectify(form, data=data) + obj = super().objectify(form, data=data) if self.creating: obj.created_by = self.request.user return obj @@ -158,7 +154,7 @@ class ExportMasterView(MasterView): user = export.created_by if not user: return "" - text = six.text_type(user) + text = str(user) if self.request.has_perm('users.view'): url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(text, url) @@ -175,12 +171,8 @@ class ExportMasterView(MasterView): export = self.get_instance() path = self.get_file_path(export) response = FileResponse(path, request=self.request) - if six.PY3: - response.headers['Content-Length'] = str(os.path.getsize(path)) - response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename) - else: - response.headers[b'Content-Length'] = six.binary_type(os.path.getsize(path)) - response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(export.filename) + response.headers['Content-Length'] = str(os.path.getsize(path)) + response.headers['Content-Disposition'] = 'attachment; filename="{}"'.format(export.filename) return response def delete_instance(self, export): @@ -195,4 +187,4 @@ class ExportMasterView(MasterView): shutil.rmtree(dirname) # continue w/ normal deletion - super(ExportMasterView, self).delete_instance(export) + super().delete_instance(export) diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index 43ba211d..462df51d 100644 --- a/tailbone/views/poser/reports.py +++ b/tailbone/views/poser/reports.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,12 +24,8 @@ Poser Report Views """ -from __future__ import unicode_literals, absolute_import - import os -import six - from rattail.util import simple_error import colander @@ -95,7 +91,7 @@ class PoserReportView(PoserMasterView): return self.poser_handler.get_all_reports(ignore_errors=False) def configure_grid(self, g): - super(PoserReportView, self).configure_grid(g) + super().configure_grid(g) g.sorters['report_key'] = g.make_simple_sorter('report_key', foldcase=True) g.sorters['report_name'] = g.make_simple_sorter('report_name', foldcase=True) @@ -157,7 +153,7 @@ class PoserReportView(PoserMasterView): return report def configure_form(self, f): - super(PoserReportView, self).configure_form(f) + super().configure_form(f) report = f.model_instance # report_key @@ -179,7 +175,7 @@ class PoserReportView(PoserMasterView): f.set_helptext('flavor', "Determines the type of sample code to generate.") flavors = self.poser_handler.get_supported_report_flavors() values = [(key, flavor['description']) - for key, flavor in six.iteritems(flavors)] + for key, flavor in flavors.items()] f.set_widget('flavor', dfwidget.SelectWidget(values=values)) f.set_validator('flavor', colander.OneOf(flavors)) if flavors: @@ -231,7 +227,7 @@ class PoserReportView(PoserMasterView): return report def configure_row_grid(self, g): - super(PoserReportView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_renderer('id', self.render_id_str) diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py index 14c97a61..3d3543c7 100644 --- a/tailbone/views/poser/views.py +++ b/tailbone/views/poser/views.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Poser Views for Views... """ -from __future__ import unicode_literals, absolute_import - -import six - import colander from .master import PoserMasterView @@ -68,7 +64,7 @@ class PoserViewView(PoserMasterView): return self.make_form({}) def configure_form(self, f): - super(PoserViewView, self).configure_form(f) + super().configure_form(f) view = f.model_instance # key @@ -224,28 +220,28 @@ class PoserViewView(PoserMasterView): }, }} - for key, views in six.iteritems(everything['rattail']): - for vkey, view in six.iteritems(views): + for key, views in everything['rattail'].items(): + for vkey, view in views.items(): view['options'] = [vkey] providers = get_all_providers(self.rattail_config) - for provider in six.itervalues(providers): + for provider in providers.values(): # loop thru provider top-level groups - for topkey, groups in six.iteritems(provider.get_provided_views()): + for topkey, groups in provider.get_provided_views().items()): # get or create top group topgroup = everything.setdefault(topkey, {}) # loop thru provider view groups - for key, views in six.iteritems(groups): + for key, views in groups.items(): # add group to top group, if it's new if key not in topgroup: topgroup[key] = views # also must init the options for group - for vkey, view in six.iteritems(views): + for vkey, view in views.items(): view['options'] = [vkey] else: # otherwise must "update" existing group @@ -254,7 +250,7 @@ class PoserViewView(PoserMasterView): stdgroup = topgroup[key] # loop thru views within provider group - for vkey, view in six.iteritems(views): + for vkey, view in views.items(): # add view to group if it's new if vkey not in stdgroup: @@ -270,8 +266,8 @@ class PoserViewView(PoserMasterView): settings = [] view_settings = self.collect_available_view_settings() - for topgroup in six.itervalues(view_settings): - for view_section, section_settings in six.iteritems(topgroup): + for topgroup in view_settings.values(): + for view_section, section_settings in topgroup.items(): for key in section_settings: settings.append({'section': 'tailbone.includes', 'option': key}) @@ -282,25 +278,25 @@ class PoserViewView(PoserMasterView): input_file_templates=True): # first get normal context - context = super(PoserViewView, self).configure_get_context( + context = super().configure_get_context( simple_settings=simple_settings, input_file_templates=input_file_templates) # first add available options view_settings = self.collect_available_view_settings() view_options = {} - for topgroup in six.itervalues(view_settings): - for key, views in six.iteritems(topgroup): - for vkey, view in six.iteritems(views): + for topgroup in view_settings.values(): + for key, views in topgroup.items(): + for vkey, view in views.items(): view_options[vkey] = view['options'] context['view_options'] = view_options # then add all available settings as sorted (key, label) options - for topkey, topgroup in six.iteritems(view_settings): + for topkey, topgroup in view_settings.items(): for key in list(topgroup): settings = topgroup[key] settings = [(key, setting.get('label', key)) - for key, setting in six.iteritems(settings)] + for key, setting in settings.items()] settings.sort(key=lambda itm: itm[1]) topgroup[key] = settings context['view_settings'] = view_settings @@ -308,7 +304,7 @@ class PoserViewView(PoserMasterView): return context def configure_flash_settings_saved(self): - super(PoserViewView, self).configure_flash_settings_saved() + super().configure_flash_settings_saved() self.request.session.flash("Please restart the web app!", 'warning') diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py index 169f324e..3f47ba3e 100644 --- a/tailbone/views/progress.py +++ b/tailbone/views/progress.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Progress Views """ -from __future__ import unicode_literals, absolute_import - -import six - from tailbone.progress import get_progress_session @@ -44,7 +40,7 @@ def progress(request): bits = session.get('extra_session_bits') if bits: - for key, value in six.iteritems(bits): + for key, value in bits.items(): request.session[key] = value elif session.get('error'): diff --git a/tailbone/views/tempmon/appliances.py b/tailbone/views/tempmon/appliances.py index c523ae78..4ce52009 100644 --- a/tailbone/views/tempmon/appliances.py +++ b/tailbone/views/tempmon/appliances.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,11 +24,9 @@ Views for tempmon appliances """ -from __future__ import unicode_literals, absolute_import - +import io import os -import six from PIL import Image from rattail_tempmon.db import model as tempmon @@ -68,7 +66,7 @@ class TempmonApplianceView(MasterView): ] def configure_grid(self, g): - super(TempmonApplianceView, self).configure_grid(g) + super().configure_grid(g) # name g.set_sort_defaults('name') @@ -94,7 +92,7 @@ class TempmonApplianceView(MasterView): return HTML.tag('div', class_='image-frame', c=[helper, image]) def configure_form(self, f): - super(TempmonApplianceView, self).configure_form(f) + super().configure_form(f) # name f.set_validator('name', self.unique_name) @@ -122,7 +120,7 @@ class TempmonApplianceView(MasterView): f.remove_field('probes') def template_kwargs_view(self, **kwargs): - kwargs = super(TempmonApplianceView, self).template_kwargs_view(**kwargs) + kwargs = super().template_kwargs_view(**kwargs) appliance = kwargs['instance'] kwargs['probes_data'] = self.normalize_probes(appliance.probes) @@ -176,13 +174,13 @@ class TempmonApplianceView(MasterView): im = Image.open(f) im.thumbnail((600, 600), Image.ANTIALIAS) - data = six.BytesIO() + data = io.BytesIO() im.save(data, 'JPEG') appliance.image_normal = data.getvalue() data.close() im.thumbnail((150, 150), Image.ANTIALIAS) - data = six.BytesIO() + data = io.BytesIO() im.save(data, 'JPEG') appliance.image_thumbnail = data.getvalue() data.close() diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 8b9361b7..addf153c 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2022 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,10 +24,6 @@ Vendor Views """ -from __future__ import unicode_literals, absolute_import - -import six - from rattail.db import model from webhelpers2.html import tags @@ -158,7 +154,7 @@ class VendorView(MasterView): person = vendor.contact if not person: return "" - text = six.text_type(person) + text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) @@ -198,7 +194,7 @@ class VendorView(MasterView): data, **kwargs) supported_vendor_settings = self.configure_get_supported_vendor_settings() - for setting in six.itervalues(supported_vendor_settings): + for setting in supported_vendor_settings.values(): name = 'rattail.vendor.{}'.format(setting['key']) settings.append({'name': name, 'value': data[name]}) @@ -211,7 +207,7 @@ class VendorView(MasterView): names = [] supported_vendor_settings = self.configure_get_supported_vendor_settings() - for setting in six.itervalues(supported_vendor_settings): + for setting in supported_vendor_settings.values(): names.append('rattail.vendor.{}'.format(setting['key'])) if names: @@ -236,7 +232,7 @@ class VendorView(MasterView): settings[key] = { 'key': key, 'value': vendor.uuid if vendor else None, - 'label': six.text_type(vendor) if vendor else None, + 'label': str(vendor) if vendor else None, } return settings From c887412825696fc7fe9cd3032d06b28dada4d1b8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Jul 2024 19:06:04 -0500 Subject: [PATCH 034/211] fix: fix syntax bug --- tailbone/views/poser/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/poser/views.py b/tailbone/views/poser/views.py index 3d3543c7..27efd549 100644 --- a/tailbone/views/poser/views.py +++ b/tailbone/views/poser/views.py @@ -228,7 +228,7 @@ class PoserViewView(PoserMasterView): for provider in providers.values(): # loop thru provider top-level groups - for topkey, groups in provider.get_provided_views().items()): + for topkey, groups in provider.get_provided_views().items(): # get or create top group topgroup = everything.setdefault(topkey, {}) From db67630363ffc7f8d4d7844c91f18b67bbcd57f4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 1 Jul 2024 23:20:09 -0500 Subject: [PATCH 035/211] =?UTF-8?q?bump:=20version=200.11.5=20=E2=86=92=20?= =?UTF-8?q?0.11.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ setup.cfg | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 510aa6a1..9410fe3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.11.6 (2024-07-01) + +### Fix + +- set explicit referrer when changing dbkey + +- remove references, dependency for `six` package + ## v0.11.5 (2024-06-30) ### Fix diff --git a/setup.cfg b/setup.cfg index 8afd9be4..17f6b151 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.5 +version = 0.11.6 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From aab4dec27ebadd60da81efcdf906a049c7af2cea Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 09:05:51 -0500 Subject: [PATCH 036/211] fix: add stacklevel to deprecation warnings --- tailbone/views/batch/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 84ef451f..f4f74a34 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -853,7 +853,7 @@ class BatchMasterView(MasterView): if isinstance(field.widget, forms.widgets.PlainSelectWidget): warnings.warn("PlainSelectWidget is deprecated; " "please use deform.widget.SelectWidget instead", - DeprecationWarning) + DeprecationWarning, stacklevel=2) field.widget = dfwidget.SelectWidget(values=field.widget.values) if not schema: From d72d6f8c7c143c4941169a1b0ad3ee6f2b025cbe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 11:14:03 -0500 Subject: [PATCH 037/211] fix: require zope.sqlalchemy >= 1.5 so we can do away with some old cruft, since latest zope.sqlalchemy is 3.1 from 2023-09-12 --- docs/api/db.rst | 6 ++ docs/index.rst | 1 + setup.cfg | 2 +- tailbone/db.py | 231 +++++++++++++++++++++++------------------------ tests/test_db.py | 7 ++ 5 files changed, 129 insertions(+), 118 deletions(-) create mode 100644 docs/api/db.rst create mode 100644 tests/test_db.py diff --git a/docs/api/db.rst b/docs/api/db.rst new file mode 100644 index 00000000..ace21b68 --- /dev/null +++ b/docs/api/db.rst @@ -0,0 +1,6 @@ + +``tailbone.db`` +=============== + +.. automodule:: tailbone.db + :members: diff --git a/docs/index.rst b/docs/index.rst index db05d0c1..3ca6d4e2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,6 +44,7 @@ Package API: api/api/batch/core api/api/batch/ordering + api/db api/diffs api/forms api/forms.widgets diff --git a/setup.cfg b/setup.cfg index 17f6b151..c42ff675 100644 --- a/setup.cfg +++ b/setup.cfg @@ -61,7 +61,7 @@ install_requires = transaction waitress WebHelpers2 - zope.sqlalchemy + zope.sqlalchemy>=1.5 [options.packages.find] diff --git a/tailbone/db.py b/tailbone/db.py index 4a6821f9..8b37f399 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -21,14 +21,13 @@ # ################################################################################ """ -Database Stuff +Database sessions etc. """ import sqlalchemy as sa from zope.sqlalchemy import datamanager import sqlalchemy_continuum as continuum from sqlalchemy.orm import sessionmaker, scoped_session -from pkg_resources import get_distribution, parse_version from rattail.db import SessionBase from rattail.db.continuum import versioning_manager @@ -43,23 +42,28 @@ TrainwreckSession = scoped_session(sessionmaker()) # empty dict for now, this must populated on app startup (if needed) ExtraTrainwreckSessions = {} -# some of the logic below may need to vary somewhat, based on which version of -# zope.sqlalchemy we have installed -zope_sqlalchemy_version = get_distribution('zope.sqlalchemy').version -zope_sqlalchemy_version_parsed = parse_version(zope_sqlalchemy_version) - class TailboneSessionDataManager(datamanager.SessionDataManager): - """Integrate a top level sqlalchemy session transaction into a zope transaction + """ + Integrate a top level sqlalchemy session transaction into a zope + transaction One phase variant. .. note:: - This class appears to be necessary in order for the Continuum - integration to work alongside the Zope transaction integration. + + This class appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It subclasses + ``zope.sqlalchemy.datamanager.SessionDataManager`` but injects + some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and + is sort of monkey-patched into the mix. """ def tpc_vote(self, trans): + """ """ # for a one phase data manager commit last in tpc_vote if self.tx is not None: # there may have been no work to do @@ -71,126 +75,120 @@ class TailboneSessionDataManager(datamanager.SessionDataManager): self._finish('committed') -def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False): - """Join a session to a transaction using the appropriate datamanager. +def join_transaction( + session, + initial_state=datamanager.STATUS_ACTIVE, + transaction_manager=datamanager.zope_transaction.manager, + keep_session=False, +): + """ + Join a session to a transaction using the appropriate datamanager. - It is safe to call this multiple times, if the session is already joined - then it just returns. + It is safe to call this multiple times, if the session is already + joined then it just returns. - `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY + `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or + STATUS_READONLY - If using the default initial status of STATUS_ACTIVE, you must ensure that - mark_changed(session) is called when data is written to the database. + If using the default initial status of STATUS_ACTIVE, you must + ensure that mark_changed(session) is called when data is written + to the database. - The ZopeTransactionExtesion SessionExtension can be used to ensure that this is - called automatically after session write operations. + The ZopeTransactionExtesion SessionExtension can be used to ensure + that this is called automatically after session write operations. .. note:: - This function is copied from upstream, and tweaked so that our custom - :class:`TailboneSessionDataManager` will be used. + + This function appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It overrides ``zope.sqlalchemy.datamanager.join_transaction()`` + to ensure the custom :class:`TailboneSessionDataManager` is + used, and is sort of monkey-patched into the mix. """ # the upstream internals of this function has changed a little over time. # unfortunately for us, that means we must include each variant here. - if zope_sqlalchemy_version_parsed >= parse_version('1.1'): # 1.1+ - if datamanager._SESSION_STATE.get(session, None) is None: - if session.twophase: - DataManager = datamanager.TwoPhaseSessionDataManager - else: - DataManager = TailboneSessionDataManager - DataManager(session, initial_state, transaction_manager, keep_session=keep_session) - - else: # pre-1.1 - if datamanager._SESSION_STATE.get(id(session), None) is None: - if session.twophase: - DataManager = datamanager.TwoPhaseSessionDataManager - else: - DataManager = TailboneSessionDataManager - DataManager(session, initial_state, transaction_manager, keep_session=keep_session) + if datamanager._SESSION_STATE.get(session, None) is None: + if session.twophase: + DataManager = datamanager.TwoPhaseSessionDataManager + else: + DataManager = TailboneSessionDataManager + DataManager(session, initial_state, transaction_manager, keep_session=keep_session) -if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+ - - class ZopeTransactionEvents(datamanager.ZopeTransactionEvents): - """ - Record that a flush has occurred on a session's - connection. This allows the DataManager to rollback rather - than commit on read only transactions. - - .. note:: - This class is copied from upstream, and tweaked so that our - custom :func:`join_transaction()` will be used. - """ - - def after_begin(self, session, transaction, connection): - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) - - def after_attach(self, session, instance): - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) - - def join_transaction(self, session): - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) - -else: # pre-1.2 - - class ZopeTransactionExtension(datamanager.ZopeTransactionExtension): - """ - Record that a flush has occurred on a session's - connection. This allows the DataManager to rollback rather - than commit on read only transactions. - - .. note:: - This class is copied from upstream, and tweaked so that our - custom :func:`join_transaction()` will be used. - """ - - def after_begin(self, session, transaction, connection): - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) - - def after_attach(self, session, instance): - join_transaction(session, self.initial_state, - self.transaction_manager, self.keep_session) - - -def register(session, initial_state=datamanager.STATUS_ACTIVE, - transaction_manager=datamanager.zope_transaction.manager, keep_session=False): - """Register ZopeTransaction listener events on the - given Session or Session factory/class. - - This function requires at least SQLAlchemy 0.7 and makes use - of the newer sqlalchemy.event package in order to register event listeners - on the given Session. - - The session argument here may be a Session class or subclass, a - sessionmaker or scoped_session instance, or a specific Session instance. - Event listening will be specific to the scope of the type of argument - passed, including specificity to its subclass as well as its identity. +class ZopeTransactionEvents(datamanager.ZopeTransactionEvents): + """ + Record that a flush has occurred on a session's connection. This + allows the DataManager to rollback rather than commit on read only + transactions. .. note:: - This function is copied from upstream, and tweaked so that our custom - :class:`ZopeTransactionExtension` will be used. + + This class appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It subclasses + ``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but + overrides various methods to ensure the custom + :func:`join_transaction()` is called, and is sort of + monkey-patched into the mix. + """ + + def after_begin(self, session, transaction, connection): + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + def after_attach(self, session, instance): + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + def join_transaction(self, session): + """ """ + join_transaction(session, self.initial_state, + self.transaction_manager, self.keep_session) + + +def register( + session, + initial_state=datamanager.STATUS_ACTIVE, + transaction_manager=datamanager.zope_transaction.manager, + keep_session=False, +): + """ + Register ZopeTransaction listener events on the given Session or + Session factory/class. + + This function requires at least SQLAlchemy 0.7 and makes use of + the newer sqlalchemy.event package in order to register event + listeners on the given Session. + + The session argument here may be a Session class or subclass, a + sessionmaker or scoped_session instance, or a specific Session + instance. Event listening will be specific to the scope of the + type of argument passed, including specificity to its subclass as + well as its identity. + + .. note:: + + This function appears to be necessary in order for the + SQLAlchemy-Continuum integration to work alongside the Zope + transaction integration. + + It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to + ensure the custom :class:`ZopeTransactionEvents` is used. """ from sqlalchemy import event - if zope_sqlalchemy_version_parsed >= parse_version('1.2'): # 1.2+ - - ext = ZopeTransactionEvents( - initial_state=initial_state, - transaction_manager=transaction_manager, - keep_session=keep_session, - ) - - else: # pre-1.2 - - ext = ZopeTransactionExtension( - initial_state=initial_state, - transaction_manager=transaction_manager, - keep_session=keep_session, - ) + ext = ZopeTransactionEvents( + initial_state=initial_state, + transaction_manager=transaction_manager, + keep_session=keep_session, + ) event.listen(session, "after_begin", ext.after_begin) event.listen(session, "after_attach", ext.after_attach) @@ -199,9 +197,8 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE, event.listen(session, "after_bulk_delete", ext.after_bulk_delete) event.listen(session, "before_commit", ext.before_commit) - if zope_sqlalchemy_version_parsed >= parse_version('1.5'): # 1.5+ - if datamanager.SA_GE_14: - event.listen(session, "do_orm_execute", ext.do_orm_execute) + if datamanager.SA_GE_14: + event.listen(session, "do_orm_execute", ext.do_orm_execute) register(Session) diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 00000000..88cb9d41 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8; -*- + +# TODO: add real tests at some point but this at least gives us basic +# coverage when running this "test" module alone + +from tailbone import db + From 1f38894f02e6279e2180d87185bef6d3651a0065 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 14:14:15 -0500 Subject: [PATCH 038/211] fix: include edit profile email/phone dialogs only if user has perms otherwise we get JS errors when page loads --- tailbone/templates/people/view_profile.mako | 238 ++++++++++---------- 1 file changed, 122 insertions(+), 116 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 3520d924..22b4b8c6 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -461,72 +461,75 @@ </${b}-table> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="deletePhoneShowDialog" - % else: - :active.sync="deletePhoneShowDialog" - % endif - > - <div class="modal-card"> + % if request.has_perm('people_profile.edit_person'): - <header class="modal-card-head"> - <p class="modal-card-title">Delete Phone</p> - </header> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deletePhoneShowDialog" + % else: + :active.sync="deletePhoneShowDialog" + % endif + > + <div class="modal-card"> - <section class="modal-card-body"> - <p class="block">Really delete this phone number?</p> - <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p> - </section> + <header class="modal-card-head"> + <p class="modal-card-title">Delete Phone</p> + </header> - <footer class="modal-card-foot"> - <b-button type="is-danger" - @click="deletePhoneSave()" - :disabled="deletePhoneSaving" - icon-pack="fas" - icon-left="trash"> - {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }} - </b-button> - <b-button @click="deletePhoneShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </${b}-modal> + <section class="modal-card-body"> + <p class="block">Really delete this phone number?</p> + <p class="block has-text-weight-bold">{{ deletePhoneNumber }}</p> + </section> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="preferPhoneShowDialog" - % else: - :active.sync="preferPhoneShowDialog" - % endif - > - <div class="modal-card"> + <footer class="modal-card-foot"> + <b-button type="is-danger" + @click="deletePhoneSave()" + :disabled="deletePhoneSaving" + icon-pack="fas" + icon-left="trash"> + {{ deletePhoneSaving ? "Working, please wait..." : "Delete" }} + </b-button> + <b-button @click="deletePhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> - <header class="modal-card-head"> - <p class="modal-card-title">Set Preferred Phone</p> - </header> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="preferPhoneShowDialog" + % else: + :active.sync="preferPhoneShowDialog" + % endif + > + <div class="modal-card"> - <section class="modal-card-body"> - <p class="block">Really make this the preferred phone number?</p> - <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p> - </section> + <header class="modal-card-head"> + <p class="modal-card-title">Set Preferred Phone</p> + </header> - <footer class="modal-card-foot"> - <b-button type="is-primary" - @click="preferPhoneSave()" - :disabled="preferPhoneSaving" - icon-pack="fas" - icon-left="save"> - {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }} - </b-button> - <b-button @click="preferPhoneShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </${b}-modal> + <section class="modal-card-body"> + <p class="block">Really make this the preferred phone number?</p> + <p class="block has-text-weight-bold">{{ preferPhoneNumber }}</p> + </section> + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="preferPhoneSave()" + :disabled="preferPhoneSaving" + icon-pack="fas" + icon-left="save"> + {{ preferPhoneSaving ? "Working, please wait..." : "Set Preferred" }} + </b-button> + <b-button @click="preferPhoneShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + % endif </div> </div> </div> @@ -694,72 +697,75 @@ </${b}-table> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="deleteEmailShowDialog" - % else: - :active.sync="deleteEmailShowDialog" - % endif - > - <div class="modal-card"> + % if request.has_perm('people_profile.edit_person'): - <header class="modal-card-head"> - <p class="modal-card-title">Delete Email</p> - </header> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deleteEmailShowDialog" + % else: + :active.sync="deleteEmailShowDialog" + % endif + > + <div class="modal-card"> - <section class="modal-card-body"> - <p class="block">Really delete this email address?</p> - <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p> - </section> + <header class="modal-card-head"> + <p class="modal-card-title">Delete Email</p> + </header> - <footer class="modal-card-foot"> - <b-button type="is-danger" - @click="deleteEmailSave()" - :disabled="deleteEmailSaving" - icon-pack="fas" - icon-left="trash"> - {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }} - </b-button> - <b-button @click="deleteEmailShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </${b}-modal> + <section class="modal-card-body"> + <p class="block">Really delete this email address?</p> + <p class="block has-text-weight-bold">{{ deleteEmailAddress }}</p> + </section> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="preferEmailShowDialog" - % else: - :active.sync="preferEmailShowDialog" - % endif - > - <div class="modal-card"> + <footer class="modal-card-foot"> + <b-button type="is-danger" + @click="deleteEmailSave()" + :disabled="deleteEmailSaving" + icon-pack="fas" + icon-left="trash"> + {{ deleteEmailSaving ? "Working, please wait..." : "Delete" }} + </b-button> + <b-button @click="deleteEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> - <header class="modal-card-head"> - <p class="modal-card-title">Set Preferred Email</p> - </header> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="preferEmailShowDialog" + % else: + :active.sync="preferEmailShowDialog" + % endif + > + <div class="modal-card"> - <section class="modal-card-body"> - <p class="block">Really make this the preferred email address?</p> - <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p> - </section> + <header class="modal-card-head"> + <p class="modal-card-title">Set Preferred Email</p> + </header> - <footer class="modal-card-foot"> - <b-button type="is-primary" - @click="preferEmailSave()" - :disabled="preferEmailSaving" - icon-pack="fas" - icon-left="save"> - {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }} - </b-button> - <b-button @click="preferEmailShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </${b}-modal> + <section class="modal-card-body"> + <p class="block">Really make this the preferred email address?</p> + <p class="block has-text-weight-bold">{{ preferEmailAddress }}</p> + </section> + <footer class="modal-card-foot"> + <b-button type="is-primary" + @click="preferEmailSave()" + :disabled="preferEmailSaving" + icon-pack="fas" + icon-left="save"> + {{ preferEmailSaving ? "Working, please wait..." : "Set Preferred" }} + </b-button> + <b-button @click="preferEmailShowDialog = false"> + Cancel + </b-button> + </footer> + </div> + </${b}-modal> + + % endif </div> </div> </div> From 9146cdc835f63518c7ffc2bb98a9fdbd310ce00f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 14:20:48 -0500 Subject: [PATCH 039/211] fix: allow view supplements to add to profile member context --- tailbone/views/people.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index d8e36ec9..d3a82dc0 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -742,10 +742,15 @@ class PersonView(MasterView): membership = app.get_membership_handler() data = OrderedDict() - members = membership.get_members_for_account_holder(person) for member in members: - data[member.uuid] = self.get_context_member(member) + context = self.get_context_member(member) + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_member'): + context = supp.get_context_for_member(member, context) + + data[member.uuid] = context return list(data.values()) From e23193b73083c33c0b79e74be572d406e3e2a16e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 16:45:10 -0500 Subject: [PATCH 040/211] fix: cast enum as list to satisfy deform widget seems to only be an issue for deform 2.0.15+ --- tailbone/views/batch/product.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/product.py b/tailbone/views/batch/product.py index af8374ac..590c3ff0 100644 --- a/tailbone/views/batch/product.py +++ b/tailbone/views/batch/product.py @@ -46,7 +46,7 @@ class ExecutionOptions(colander.Schema): action = colander.SchemaNode( colander.String(), validator=colander.OneOf(ACTION_OPTIONS), - widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items())) + widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items()))) class ProductBatchView(BatchMasterView): From 5e11a2ecf6ab9e258d799ffdb43a8a37a4b27613 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 2 Jul 2024 22:47:03 -0500 Subject: [PATCH 041/211] fix: expand POD image URL setting input --- tailbone/templates/products/configure.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 10f3c0e5..6121af67 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -41,8 +41,8 @@ <b-input name="rattail.pod.pictures.gtin.root_url" v-model="simpleSettings['rattail.pod.pictures.gtin.root_url']" :disabled="!simpleSettings['tailbone.products.show_pod_image']" - @input="settingsNeedSaved = true"> - </b-input> + @input="settingsNeedSaved = true" + expanded /> </b-field> </div> From 76897c24dee986b60f63cc0d33f4b18de47c1eb1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 08:20:31 -0500 Subject: [PATCH 042/211] =?UTF-8?q?bump:=20version=200.11.6=20=E2=86=92=20?= =?UTF-8?q?0.11.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 16 ++++++++++++++++ setup.cfg | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9410fe3f..672bd2b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 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.11.7 (2024-07-04) + +### Fix + +- add stacklevel to deprecation warnings + +- require zope.sqlalchemy >= 1.5 + +- include edit profile email/phone dialogs only if user has perms + +- allow view supplements to add to profile member context + +- cast enum as list to satisfy deform widget + +- expand POD image URL setting input + ## v0.11.6 (2024-07-01) ### Fix diff --git a/setup.cfg b/setup.cfg index c42ff675..e00b92f2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.6 +version = 0.11.7 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 793a15883e9b16d2f5a8bcedecdafaff63460ddb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 15:59:05 -0500 Subject: [PATCH 043/211] fix: fix grid action icons for datasync/configure, per oruga --- tailbone/templates/datasync/configure.mako | 66 ++++++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 8b0f5e51..a512745c 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -157,15 +157,29 @@ <a href="#" class="grid-action" @click.prevent="editProfile(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" class="grid-action has-text-danger" @click.prevent="deleteProfile(props.row)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> </${b}-table-column> <template #empty> @@ -314,15 +328,29 @@ v-slot="props"> <a href="#" @click.prevent="editProfileWatcherKwarg(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" class="has-text-danger" @click.prevent="deleteProfileWatcherKwarg(props.row)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> </${b}-table-column> <template #empty> @@ -372,15 +400,29 @@ <a href="#" class="grid-action" @click.prevent="editProfileConsumer(props.row)"> - <i class="fas fa-edit"></i> - Edit + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="edit" /> + <span>Edit</span> + </span> + % else: + <i class="fas fa-edit"></i> + Edit + % endif </a> <a href="#" class="grid-action has-text-danger" @click.prevent="deleteProfileConsumer(props.row)"> - <i class="fas fa-trash"></i> - Delete + % if request.use_oruga: + <span class="icon-text"> + <o-icon icon="trash" /> + <span>Delete</span> + </span> + % else: + <i class="fas fa-trash"></i> + Delete + % endif </a> </${b}-table-column> <template #empty> From 89d7009a1855e2062a2a1e8cfd107e801a3356b3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 18:21:06 -0500 Subject: [PATCH 044/211] fix: allow view supplements to add extra links for profile employee tab --- tailbone/templates/people/view_profile.mako | 250 ++++++++++---------- tailbone/views/people.py | 6 + 2 files changed, 136 insertions(+), 120 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 22b4b8c6..4767a924 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1274,141 +1274,151 @@ </div> - <div> - <div class="buttons"> + <div style="display: flex; gap: 0.75rem;"> - % if request.has_perm('people_profile.toggle_employee'): + % if request.has_perm('people_profile.toggle_employee'): - <b-button v-if="!employee.current" - type="is-primary" - @click="startEmployeeInit()"> - ${person} is now an Employee - </b-button> + <b-button v-if="!employee.current" + type="is-primary" + @click="startEmployeeInit()"> + ${person} is now an Employee + </b-button> - <b-button v-if="employee.current" - type="is-primary" - @click="stopEmployeeInit()"> - ${person} is no longer an Employee - </b-button> + <b-button v-if="employee.current" + type="is-primary" + @click="stopEmployeeInit()"> + ${person} is no longer an Employee + </b-button> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="startEmployeeShowDialog" - % else: - :active.sync="startEmployeeShowDialog" - % endif - > - <div class="modal-card"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="startEmployeeShowDialog" + % else: + :active.sync="startEmployeeShowDialog" + % endif + > + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Employee Start</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Employee Start</p> + </header> - <section class="modal-card-body"> - <b-field label="Employee Number"> - <b-input v-model="startEmployeeID"></b-input> - </b-field> - <b-field label="Start Date"> - <tailbone-datepicker v-model="startEmployeeStartDate" - ref="startEmployeeStartDate" /> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="Employee Number"> + <b-input v-model="startEmployeeID"></b-input> + </b-field> + <b-field label="Start Date"> + <tailbone-datepicker v-model="startEmployeeStartDate" + ref="startEmployeeStartDate" /> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button @click="startEmployeeShowDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - @click="startEmployeeSave()" - :disabled="startEmployeeSaveDisabled" - icon-pack="fas" - icon-left="save"> - {{ startEmployeeSaving ? "Working, please wait..." : "Save" }} - </b-button> - </footer> - </div> - </${b}-modal> + <footer class="modal-card-foot"> + <b-button @click="startEmployeeShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="startEmployeeSave()" + :disabled="startEmployeeSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ startEmployeeSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="stopEmployeeShowDialog" - % else: - :active.sync="stopEmployeeShowDialog" - % endif - > - <div class="modal-card"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="stopEmployeeShowDialog" + % else: + :active.sync="stopEmployeeShowDialog" + % endif + > + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Employee End</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Employee End</p> + </header> - <section class="modal-card-body"> - <b-field label="End Date" - :type="stopEmployeeEndDate ? null : 'is-danger'"> - <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker> - </b-field> - <b-field label="Revoke Internal App Access"> - <b-checkbox v-model="stopEmployeeRevokeAccess"> - </b-checkbox> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="End Date" + :type="stopEmployeeEndDate ? null : 'is-danger'"> + <tailbone-datepicker v-model="stopEmployeeEndDate"></tailbone-datepicker> + </b-field> + <b-field label="Revoke Internal App Access"> + <b-checkbox v-model="stopEmployeeRevokeAccess"> + </b-checkbox> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button @click="stopEmployeeShowDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - @click="stopEmployeeSave()" - :disabled="stopEmployeeSaveDisabled" - icon-pack="fas" - icon-left="save"> - {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }} - </b-button> - </footer> - </div> - </${b}-modal> - % endif + <footer class="modal-card-foot"> + <b-button @click="stopEmployeeShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="stopEmployeeSave()" + :disabled="stopEmployeeSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ stopEmployeeSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif - % if request.has_perm('people_profile.edit_employee_history'): - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="editEmployeeHistoryShowDialog" - % else: - :active.sync="editEmployeeHistoryShowDialog" - % endif - > - <div class="modal-card"> + % if request.has_perm('people_profile.edit_employee_history'): + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editEmployeeHistoryShowDialog" + % else: + :active.sync="editEmployeeHistoryShowDialog" + % endif + > + <div class="modal-card"> - <header class="modal-card-head"> - <p class="modal-card-title">Edit Employee History</p> - </header> + <header class="modal-card-head"> + <p class="modal-card-title">Edit Employee History</p> + </header> - <section class="modal-card-body"> - <b-field label="Start Date"> - <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker> - </b-field> - <b-field label="End Date"> - <tailbone-datepicker v-model="editEmployeeHistoryEndDate" - :disabled="!editEmployeeHistoryEndDateRequired"> - </tailbone-datepicker> - </b-field> - </section> + <section class="modal-card-body"> + <b-field label="Start Date"> + <tailbone-datepicker v-model="editEmployeeHistoryStartDate"></tailbone-datepicker> + </b-field> + <b-field label="End Date"> + <tailbone-datepicker v-model="editEmployeeHistoryEndDate" + :disabled="!editEmployeeHistoryEndDateRequired"> + </tailbone-datepicker> + </b-field> + </section> - <footer class="modal-card-foot"> - <b-button @click="editEmployeeHistoryShowDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - @click="editEmployeeHistorySave()" - :disabled="editEmployeeHistorySaveDisabled" - icon-pack="fas" - icon-left="save"> - {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }} - </b-button> - </footer> - </div> - </${b}-modal> - % endif + <footer class="modal-card-foot"> + <b-button @click="editEmployeeHistoryShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="editEmployeeHistorySave()" + :disabled="editEmployeeHistorySaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ editEmployeeHistorySaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif + + <div style="display: flex; flex-direction: column; align-items: right; gap: 0.75rem;"> + + <b-button v-for="link in employee.external_links" + :key="link.url" + type="is-primary" + tag="a" :href="link.url" target="_blank" + icon-pack="fas" + icon-left="external-link-alt"> + {{ link.label }} + </b-button> % if request.has_perm('employees.view'): <b-button v-if="employee.view_url" diff --git a/tailbone/views/people.py b/tailbone/views/people.py index d3a82dc0..b9fe5c4b 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -803,6 +803,12 @@ class PersonView(MasterView): app = self.get_rattail_app() handler = app.get_employment_handler() context = handler.get_context_employee(employee) + context.setdefault('external_links', []) + + for supp in self.iter_view_supplements(): + if hasattr(supp, 'get_context_for_employee'): + context = supp.get_context_for_employee(employee, context) + context['view_url'] = self.request.route_url('employees.view', uuid=employee.uuid) return context From ddec77c37f6e6cb8b9b54666c63b0a79808ebeee Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 20:33:34 -0500 Subject: [PATCH 045/211] fix: leverage import handler method to determine command/subcommand just moved previous logic to rattail/handler --- tailbone/views/importing.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/tailbone/views/importing.py b/tailbone/views/importing.py index e9167132..48b32cc2 100644 --- a/tailbone/views/importing.py +++ b/tailbone/views/importing.py @@ -34,7 +34,6 @@ import time import sqlalchemy as sa -from rattail.exceptions import ConfigurationError from rattail.threads import Thread import colander @@ -458,22 +457,7 @@ And here is the output: return HTML.tag('div', class_='tailbone-markdown', c=[notes]) def get_cmd_for_handler(self, handler, ignore_errors=False): - handler_key = handler.get_key() - - cmd = self.rattail_config.getlist('rattail.importing', - '{}.cmd'.format(handler_key)) - if not cmd or len(cmd) != 2: - cmd = self.rattail_config.getlist('rattail.importing', - '{}.default_cmd'.format(handler_key)) - - if not cmd or len(cmd) != 2: - msg = ("Missing or invalid config; please set '{}.default_cmd' in the " - "[rattail.importing] section of your config file".format(handler_key)) - if ignore_errors: - return - raise ConfigurationError(msg) - - return cmd + return handler.get_cmd(ignore_errors=ignore_errors) def get_runas_for_handler(self, handler): handler_key = handler.get_key() From 58be7e9d5b7f62d8e4c8440e8a3d77a16f63e7b9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 21:32:46 -0500 Subject: [PATCH 046/211] fix: add tool to make user account from profile view --- tailbone/templates/people/view_profile.mako | 144 +++++++++++++++++--- tailbone/views/people.py | 40 +++++- 2 files changed, 167 insertions(+), 17 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 4767a924..0b700ca5 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1635,28 +1635,30 @@ <br /> <div id="users-accordion"> - <b-collapse class="panel" - v-for="user in users" - :key="user.uuid"> + <${b}-collapse v-for="user in users" + :key="user.uuid" + class="panel"> <div slot="trigger" + slot-scope="props" class="panel-heading" role="button"> - <strong>{{ user.username }}</strong> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + <strong>{{ user.username }}</strong> </div> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> - <div> - <div class="field-wrapper id"> - <div class="field-row"> - <label>Username</label> - <div class="field"> - {{ user.username }} - </div> - </div> - </div> + <div style="flex-grow: 1;"> + <b-field horizontal label="Username"> + {{ user.username }} + </b-field> + <b-field horizontal label="Active"> + {{ user.active ? "Yes" : "No" }} + </b-field> </div> <div> @@ -1669,13 +1671,66 @@ </div> </div> - </b-collapse> + </${b}-collapse> </div> </div> - <div v-if="!users.length"> + <div v-if="!users.length" + style="display: flex; justify-content: space-between;"> + <p>{{ person.display_name }} does not have a user account.</p> + + % if request.has_perm('users.create'): + <b-button type="primary" + icon-pack="fas" + icon-left="plus" + @click="createUserInit()"> + Create User + </b-button> + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="createUserShowDialog" + % else: + :active.sync="createUserShowDialog" + % endif + > + <div class="modal-card"> + + <header class="modal-card-head"> + <p class="modal-card-title">Create User</p> + </header> + + <section class="modal-card-body"> + <b-field label="Person"> + <span>{{ person.display_name }}</span> + </b-field> + <b-field label="Username"> + <b-input v-model="createUserUsername" + ref="username" /> + </b-field> + <b-field label="Active"> + <b-checkbox v-model="createUserActive" /> + </b-field> + </section> + + <footer class="modal-card-foot"> + <b-button @click="createUserShowDialog = false"> + Cancel + </b-button> + <b-button type="is-primary" + @click="createUserSave()" + :disabled="createUserSaveDisabled" + icon-pack="fas" + icon-left="save"> + {{ createUserSaving ? "Working, please wait..." : "Save" }} + </b-button> + </footer> + </div> + </${b}-modal> + % endif </div> + % if request.use_oruga: <o-loading v-model:active="refreshingTab" :full-page="false"></o-loading> % else: @@ -2730,6 +2785,13 @@ let UserTabData = { refreshTabURL: '${url('people.profile_tab_user', uuid=person.uuid)}', users: [], + + % if request.has_perm('users.create'): + createUserShowDialog: false, + createUserUsername: null, + createUserActive: false, + createUserSaving: false, + % endif } let UserTab = { @@ -2738,12 +2800,62 @@ props: { person: Object, }, - computed: {}, + + computed: { + + % if request.has_perm('users.create'): + + createUserSaveDisabled() { + if (this.createUserSaving) { + return true + } + if (!this.createUserUsername) { + return true + } + return false + }, + + % endif + }, + methods: { refreshTabSuccess(response) { this.users = response.data.users + this.createUserSuggestedUsername = response.data.suggested_username }, + + % if request.has_perm('users.create'): + + createUserInit() { + this.createUserUsername = this.createUserSuggestedUsername + this.createUserActive = true + this.createUserShowDialog = true + this.$nextTick(() => { + this.$refs.username.focus() + }) + }, + + createUserSave() { + this.createUserSaving = true + + let url = '${master.get_action_url('profile_make_user', instance)}' + let params = { + username: this.createUserUsername, + active: this.createUserActive, + } + + this.simplePOST(url, params, response => { + this.$emit('profile-changed', response.data) + this.createUserSaving = false + this.createUserShowDialog = false + this.refreshTab() + }, response => { + this.createUserSaving = false + }) + }, + + % endif }, } diff --git a/tailbone/views/people.py b/tailbone/views/people.py index b9fe5c4b..08e32c3c 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1280,11 +1280,40 @@ class PersonView(MasterView): """ Fetch user tab data for profile view. """ + app = self.get_rattail_app() + auth = app.get_auth_handler() person = self.get_instance() - return { + context = { 'users': self.get_context_users(person), } + if not context['users']: + context['suggested_username'] = auth.generate_unique_username(self.Session(), + person=person) + + return context + + def profile_make_user(self): + """ + Create a new user account, presumably from the profile view. + """ + app = self.get_rattail_app() + model = self.model + auth = app.get_auth_handler() + + person = self.get_instance() + if person.users: + return {'error': f"This person already has {len(person.users)} user accounts."} + + data = self.request.json_body + user = auth.make_user(session=self.Session(), + person=person, + username=data['username'], + active=data['active']) + + self.Session.flush() + return self.profile_changed_response(person) + def profile_revisions_grid(self, person): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() @@ -1787,6 +1816,15 @@ class PersonView(MasterView): route_name=f'{route_prefix}.profile_tab_user', renderer='json') + # profile - make user + config.add_route(f'{route_prefix}.profile_make_user', + f'{instance_url_prefix}/make-user', + request_method='POST') + config.add_view(cls, attr='profile_make_user', + route_name=f'{route_prefix}.profile_make_user', + permission='users.create', + renderer='json') + # profile - revisions data config.add_tailbone_permission('people_profile', 'people_profile.view_versions', From 431a4d7433d639d6b3850a8f571d1eab0b9230ce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 4 Jul 2024 23:59:06 -0500 Subject: [PATCH 047/211] =?UTF-8?q?bump:=20version=200.11.7=20=E2=86=92=20?= =?UTF-8?q?0.11.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ setup.cfg | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 672bd2b6..15fe3a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.11.8 (2024-07-04) + +### Fix + +- fix grid action icons for datasync/configure, per oruga + +- allow view supplements to add extra links for profile employee tab + +- leverage import handler method to determine command/subcommand + +- add tool to make user account from profile view + ## v0.11.7 (2024-07-04) ### Fix diff --git a/setup.cfg b/setup.cfg index e00b92f2..6e81a547 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.7 +version = 0.11.8 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 2988ff3ee937a5fadbcda853b5bad97eacde7028 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 12:50:45 -0500 Subject: [PATCH 048/211] fix: do not show flash message when changing app theme it is just distracting esp. when testing different themes --- tailbone/views/common.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 3c4b659b..7e9ddb09 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -151,8 +151,6 @@ class CommonView(View): except Exception as error: msg = "Failed to set theme: {}: {}".format(error.__class__.__name__, error) self.request.session.flash(msg, 'error') - else: - self.request.session.flash("App theme has been changed to: {}".format(theme)) referrer = self.request.params.get('referrer') or self.request.get_referrer() return self.redirect(referrer) From 735327e46b22663eeafdea588e80d5566a8dc8c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 12:53:14 -0500 Subject: [PATCH 049/211] fix: improve collapse panels for butterball theme --- tailbone/templates/custorders/create.mako | 10 +- tailbone/templates/people/view_profile.mako | 117 ++++++++++++++------ 2 files changed, 87 insertions(+), 40 deletions(-) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 9a3a2d57..63505422 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -78,15 +78,16 @@ <b-icon v-if="props.open" pack="fas" - icon="angle-down"> + icon="caret-down"> </b-icon> <span v-if="!props.open"> <b-icon pack="fas" - icon="angle-right"> + icon="caret-right"> </b-icon> </span> + <strong v-html="customerPanelHeader"></strong> </div> </template> @@ -525,15 +526,16 @@ <b-icon v-if="props.open" pack="fas" - icon="angle-down"> + icon="caret-down"> </b-icon> <span v-if="!props.open"> <b-icon pack="fas" - icon="angle-right"> + icon="caret-right"> </b-icon> </span> + <strong v-html="itemsPanelHeader"></strong> </div> </template> diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 0b700ca5..1eac6a2f 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -836,20 +836,35 @@ </div> <br /> - <b-collapse v-for="member in members" - :key="member.uuid" - class="panel" - :open="members.length == 1"> + <${b}-collapse v-for="member in members" + :key="member.uuid" + class="panel" + :open="members.length == 1"> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>{{ member._key }} - {{ member.display }}</strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + + <strong>{{ member._key }} - {{ member.display }}</strong> + </div> + </template> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> @@ -917,7 +932,7 @@ </div> </div> </div> - </b-collapse> + </${b}-collapse> </div> <div v-if="!members.length"> @@ -957,20 +972,35 @@ </div> <br /> - <b-collapse v-for="customer in customers" - :key="customer.uuid" - class="panel" - :open="customers.length == 1"> + <${b}-collapse v-for="customer in customers" + :key="customer.uuid" + class="panel" + :open="customers.length == 1"> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> - <strong>{{ customer._key }} - {{ customer.name }}</strong> - </div> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + + <strong>{{ customer._key }} - {{ customer.name }}</strong> + </div> + </template> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> @@ -1045,7 +1075,7 @@ </div> </div> </div> - </b-collapse> + </${b}-collapse> </div> <div v-if="!customers.length"> @@ -1639,15 +1669,30 @@ :key="user.uuid" class="panel"> - <div slot="trigger" - slot-scope="props" - class="panel-heading" - role="button"> - <b-icon pack="fas" - icon="caret-right"> - </b-icon> + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down" /> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right" /> + </span> + + <strong>{{ user.username }}</strong> - </div> + </div> + </template> <div class="panel-block"> <div style="display: flex; justify-content: space-between; width: 100%;"> From 19e65f5bb9f2191b90b6e81d26d105f8d26ca3db Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 13:07:08 -0500 Subject: [PATCH 050/211] fix: expand input for butterball theme --- tailbone/templates/people/configure.mako | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako index 9e6ce5fb..d821d898 100644 --- a/tailbone/templates/people/configure.mako +++ b/tailbone/templates/people/configure.mako @@ -4,7 +4,7 @@ <%def name="form_content()"> <h3 class="block is-size-3">General</h3> - <div class="block" style="padding-left: 2rem;"> + <div class="block" style="padding-left: 2rem; width: 50%;"> <b-field message="If set, grid links are to Personal tab of Profile view."> <b-checkbox name="rattail.people.straight_to_profile" @@ -28,8 +28,8 @@ message="Leave blank for default handler."> <b-input name="rattail.people.handler" v-model="simpleSettings['rattail.people.handler']" - @input="settingsNeedSaved = true"> - </b-input> + @input="settingsNeedSaved = true" + expanded /> </b-field> </div> From b7d26b6b8ccf896643fc8da2b07678e3dc8e2bf7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 14:30:52 -0500 Subject: [PATCH 051/211] fix: add xref button to customer profile, for trainwreck txn view --- tailbone/views/trainwreck/base.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 59a42301..9a6086d7 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -270,6 +270,23 @@ class TransactionView(MasterView): return kwargs + def get_xref_buttons(self, txn): + app = self.get_rattail_app() + clientele = app.get_clientele_handler() + buttons = super().get_xref_buttons(txn) + + if txn.customer_id: + customer = clientele.locate_customer_for_key(Session(), txn.customer_id) + if customer: + person = app.get_person(customer) + if person: + url = self.request.route_url('people.view_profile', uuid=person.uuid) + buttons.append(self.make_xref_button(text=str(person), + url=url, + internal=True)) + + return buttons + def get_row_data(self, transaction): return self.Session.query(self.model_row_class)\ .filter(self.model_row_class.transaction == transaction) From 16bf13787dff8b0528d20ff360273643139dba0f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 14:45:35 -0500 Subject: [PATCH 052/211] fix: add optional Transactions tab for profile view showing Trainwreck data by default --- tailbone/templates/people/configure.mako | 14 +++ tailbone/templates/people/view_profile.mako | 93 ++++++++++++++++++ tailbone/views/people.py | 103 ++++++++++++++++++++ 3 files changed, 210 insertions(+) diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako index d821d898..7d7a5618 100644 --- a/tailbone/templates/people/configure.mako +++ b/tailbone/templates/people/configure.mako @@ -33,6 +33,20 @@ </b-field> </div> + + <h3 class="block is-size-3">Profile View</h3> + <div class="block" style="padding-left: 2rem; width: 50%;"> + + <b-field> + <b-checkbox name="tailbone.people.profile.expose_transactions" + v-model="simpleSettings['tailbone.people.profile.expose_transactions']" + native-value="true" + @input="settingsNeedSaved = true"> + Show tab for Customer POS Transactions + </b-checkbox> + </b-field> + + </div> </%def> diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 1eac6a2f..9d9ab37d 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1656,6 +1656,34 @@ </${b}-tab-item> </%def> +% if expose_transactions: + + <%def name="render_transactions_tab_template()"> + <script type="text/x-template" id="transactions-tab-template"> + <div> + <transactions-grid + ref="transactionsGrid" + /> + </div> + </script> + </%def> + + <%def name="render_transactions_tab()"> + <${b}-tab-item label="Transactions" + value="transactions" + % if not request.use_oruga: + icon-pack="fas" + % endif + icon="bars"> + <transactions-tab ref="tab_transactions" + :person="person" + @profile-changed="profileChanged" /> + </${b}-tab-item> + </%def> + +% endif + + <%def name="render_user_tab_template()"> <script type="text/x-template" id="user-tab-template"> <div> @@ -1806,6 +1834,9 @@ % endif ${self.render_employee_tab()} ${self.render_notes_tab()} + % if expose_transactions: + ${self.render_transactions_tab()} + % endif ${self.render_user_tab()} </%def> @@ -1941,6 +1972,12 @@ % 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()} </%def> @@ -2824,6 +2861,49 @@ </script> </%def> +% if expose_transactions: + + <%def name="declare_transactions_tab_vars()"> + <script type="text/javascript"> + + let TransactionsTabData = {} + + let TransactionsTab = { + template: '#transactions-tab-template', + mixins: [TabMixin, SimpleRequestMixin], + props: { + person: Object, + }, + computed: {}, + methods: { + + // nb. we override this completely, just tell the grid to refresh + refreshTab() { + this.refreshingTab = true + this.$refs.transactionsGrid.loadAsyncData(null, () => { + this.refreshed = Date.now() + this.refreshingTab = false + }) + } + }, + } + + </script> + </%def> + + <%def name="make_transactions_tab_component()"> + ${self.declare_transactions_tab_vars()} + <script type="text/javascript"> + + TransactionsTab.data = function() { return TransactionsTabData } + Vue.component('transactions-tab', TransactionsTab) + <% request.register_component('transactions-tab', 'TransactionsTab') %> + + </script> + </%def> + +% endif + <%def name="declare_user_tab_vars()"> <script type="text/javascript"> @@ -3086,6 +3166,19 @@ % 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> diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 08e32c3c..2cabf1ec 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -40,6 +40,7 @@ import colander from webhelpers2.html import HTML, tags from tailbone import forms, grids +from tailbone.db import TrainwreckSession from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -487,13 +488,101 @@ class PersonView(MasterView): 'expose_customer_shoppers': self.customers_should_expose_shoppers(), 'max_one_member': app.get_membership_handler().max_one_per_person(), 'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(), + 'expose_transactions': self.should_expose_profile_transactions(), } + if context['expose_transactions']: + context['transactions_grid'] = self.profile_transactions_grid(person, empty=True) + if self.request.has_perm('people_profile.view_versions'): context['revisions_grid'] = self.profile_revisions_grid(person) return self.render_to_response('view_profile', context) + def should_expose_profile_transactions(self): + return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions', + default=False) + + def profile_transactions_grid(self, person, empty=False): + app = self.get_rattail_app() + trainwreck = app.get_trainwreck_handler() + model = trainwreck.get_model() + route_prefix = self.get_route_prefix() + if empty: + # TODO: surely there is a better way to have empty data..? but so + # much logic depends on a query, can't just pass empty list here + data = TrainwreckSession.query(model.Transaction)\ + .filter(model.Transaction.uuid == 'bogus') + else: + data = self.profile_transactions_query(person) + factory = self.get_grid_factory() + g = factory( + 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=[ + 'start_time', + 'end_time', + 'system', + 'terminal_id', + 'receipt_number', + 'cashier_name', + 'customer_id', + 'customer_name', + 'total', + ], + labels={ + 'terminal_id': "Terminal", + 'customer_id': "Customer " + app.get_customer_key_label(), + }, + filterable=True, + sortable=True, + pageable=True, + default_sortkey='end_time', + default_sortdir='desc', + component='transactions-grid', + ) + if self.request.has_perm('trainwreck.transactions.view'): + url = lambda row, i: self.request.route_url('trainwreck.transactions.view', + uuid=row.uuid) + g.main_actions.append(grids.GridAction('view', icon='eye', url=url)) + g.load_settings() + + g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) + g.set_type('total', 'currency') + + return g + + def profile_transactions_query(self, person): + """ + Method which must return the base query for the profile's POS + Transactions grid data. + """ + app = self.get_rattail_app() + customer = app.get_customer(person) + + 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 = app.get_trainwreck_handler() + model = trainwreck.get_model() + query = TrainwreckSession.query(model.Transaction)\ + .filter(model.Transaction.customer_id == customer_key) + return query + + def profile_transactions_data(self): + """ + AJAX view to return new sorted, filtered data for transactions + grid within profile view. + """ + person = self.get_instance() + grid = self.profile_transactions_grid(person) + return grid.get_table_data() + def get_context_tabchecks(self, person): app = self.get_rattail_app() membership = app.get_membership_handler() @@ -1605,6 +1694,11 @@ class PersonView(MasterView): {'section': 'rattail', 'option': 'people.handler'}, + + # Profile View + {'section': 'tailbone', + 'option': 'people.profile.expose_transactions', + 'type': bool}, ] @classmethod @@ -1873,6 +1967,15 @@ class PersonView(MasterView): permission='people_profile.delete_note', renderer='json') + # profile - transactions data + config.add_route(f'{route_prefix}.view_profile_transactions', + f'{instance_url_prefix}/profile/transactions', + request_method='GET') + config.add_view(cls, attr='profile_transactions_data', + route_name=f'{route_prefix}.view_profile_transactions', + permission=f'{permission_prefix}.view_profile', + renderer='json') + # make user for person config.add_route('{}.make_user'.format(route_prefix), '{}/make-user'.format(url_prefix), request_method='POST') From 2917463bb6460dc477566457709f614bba5f3de5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 14:49:59 -0500 Subject: [PATCH 053/211] =?UTF-8?q?bump:=20version=200.11.8=20=E2=86=92=20?= =?UTF-8?q?0.11.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 ++++++++++++++ setup.cfg | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15fe3a46..c493f7c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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.11.9 (2024-07-05) + +### Fix + +- do not show flash message when changing app theme + +- improve collapse panels for butterball theme + +- expand input for butterball theme + +- add xref button to customer profile, for trainwreck txn view + +- add optional Transactions tab for profile view + ## v0.11.8 (2024-07-04) ### Fix diff --git a/setup.cfg b/setup.cfg index 6e81a547..c87b903a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.8 +version = 0.11.9 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From 2f2ebd0f079dc47a237b3aab6b3e9c7705f6d438 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 14:57:19 -0500 Subject: [PATCH 054/211] fix: make the Members tab optional, for profile view and hidden by default --- tailbone/templates/people/configure.mako | 8 +++++++ tailbone/templates/people/view_profile.mako | 22 +++++++++++++++++--- tailbone/views/people.py | 23 ++++++++++++++------- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/tailbone/templates/people/configure.mako b/tailbone/templates/people/configure.mako index 7d7a5618..257432dc 100644 --- a/tailbone/templates/people/configure.mako +++ b/tailbone/templates/people/configure.mako @@ -37,6 +37,14 @@ <h3 class="block is-size-3">Profile View</h3> <div class="block" style="padding-left: 2rem; width: 50%;"> + <b-field> + <b-checkbox name="tailbone.people.profile.expose_members" + v-model="simpleSettings['tailbone.people.profile.expose_members']" + native-value="true" + @input="settingsNeedSaved = true"> + Show tab for Member Accounts + </b-checkbox> + </b-field> <b-field> <b-checkbox name="tailbone.people.profile.expose_transactions" v-model="simpleSettings['tailbone.people.profile.expose_transactions']" diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 9d9ab37d..8044f7c6 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -819,6 +819,7 @@ </${b}-tab-item> </%def> +% if expose_members: <%def name="render_member_tab_template()"> <script type="text/x-template" id="member-tab-template"> <div> @@ -961,6 +962,7 @@ </member-tab> </${b}-tab-item> </%def> +% endif <%def name="render_customer_tab_template()"> <script type="text/x-template" id="customer-tab-template"> @@ -1827,7 +1829,11 @@ <%def name="render_profile_tabs()"> ${self.render_personal_tab()} - ${self.render_member_tab()} + + % if expose_members: + ${self.render_member_tab()} + % endif + ${self.render_customer_tab()} % if expose_customer_shoppers: ${self.render_shopper_tab()} @@ -1965,7 +1971,11 @@ <%def name="render_this_page_template()"> ${parent.render_this_page_template()} ${self.render_personal_tab_template()} - ${self.render_member_tab_template()} + + % if expose_members: + ${self.render_member_tab_template()} + % endif + ${self.render_customer_tab_template()} % if expose_customer_shoppers: ${self.render_shopper_tab_template()} @@ -2385,6 +2395,7 @@ </script> </%def> +% if expose_members: <%def name="declare_member_tab_vars()"> <script type="text/javascript"> @@ -2430,6 +2441,7 @@ </script> </%def> +% endif <%def name="declare_customer_tab_vars()"> <script type="text/javascript"> @@ -3159,7 +3171,11 @@ <%def name="make_this_page_component()"> ${parent.make_this_page_component()} ${self.make_personal_tab_component()} - ${self.make_member_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()} diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 2cabf1ec..9b28b94d 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -488,6 +488,7 @@ class PersonView(MasterView): 'expose_customer_shoppers': self.customers_should_expose_shoppers(), 'max_one_member': app.get_membership_handler().max_one_per_person(), 'use_preferred_first_name': self.people_handler.should_use_preferred_first_name(), + 'expose_members': self.should_expose_profile_members(), 'expose_transactions': self.should_expose_profile_transactions(), } @@ -499,6 +500,10 @@ class PersonView(MasterView): return self.render_to_response('view_profile', context) + def should_expose_profile_members(self): + return self.rattail_config.get_bool('tailbone.people.profile.expose_members', + default=False) + def should_expose_profile_transactions(self): return self.rattail_config.get_bool('tailbone.people.profile.expose_transactions', default=False) @@ -585,7 +590,6 @@ class PersonView(MasterView): def get_context_tabchecks(self, person): app = self.get_rattail_app() - membership = app.get_membership_handler() clientele = app.get_clientele_handler() tabchecks = {} @@ -596,12 +600,14 @@ class PersonView(MasterView): tabchecks['personal'] = True # member - if membership.max_one_per_person(): - member = app.get_member(person) - tabchecks['member'] = bool(member and member.active) - else: - members = membership.get_members_for_account_holder(person) - tabchecks['member'] = any([m.active for m in members]) + if self.should_expose_profile_members(): + membership = app.get_membership_handler() + if membership.max_one_per_person(): + member = app.get_member(person) + tabchecks['member'] = bool(member and member.active) + else: + members = membership.get_members_for_account_holder(person) + tabchecks['member'] = any([m.active for m in members]) # customer customers = clientele.get_customers_for_account_holder(person) @@ -1696,6 +1702,9 @@ class PersonView(MasterView): # Profile View + {'section': 'tailbone', + 'option': 'people.profile.expose_members', + 'type': bool}, {'section': 'tailbone', 'option': 'people.profile.expose_transactions', 'type': bool}, From 12f8b7bdf7bde13b69e09fe156323d4a7560b97d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 5 Jul 2024 14:58:02 -0500 Subject: [PATCH 055/211] =?UTF-8?q?bump:=20version=200.11.9=20=E2=86=92=20?= =?UTF-8?q?0.11.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ setup.cfg | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c493f7c5..54b0e1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.11.10 (2024-07-05) + +### Fix + +- make the Members tab optional, for profile view + ## v0.11.9 (2024-07-05) ### Fix diff --git a/setup.cfg b/setup.cfg index c87b903a..1787343a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = Tailbone -version = 0.11.9 +version = 0.11.10 author = Lance Edgar author_email = lance@edbob.org url = http://rattailproject.org/ From a86a33445e25c3255eaa5633fea573c33a53d93e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Jul 2024 16:45:36 -0500 Subject: [PATCH 056/211] feat: drop python 3.6 support, use pyproject.toml (again) --- pyproject.toml | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ setup.cfg | 96 ---------------------------------------------- setup.py | 3 -- tox.ini | 14 +------ 4 files changed, 103 insertions(+), 112 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..bc4bb451 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,102 @@ + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "Tailbone" +version = "0.11.10" +description = "Backoffice Web Application for Rattail" +readme = "README.rst" +authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] +license = {text = "GNU GPL v3+"} +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Pyramid", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Office/Business", + "Topic :: Software Development :: Libraries :: Python Modules", +] +requires-python = ">= 3.8" +dependencies = [ + "asgiref", + "colander", + "ColanderAlchemy", + "cornice", + "cornice-swagger", + "deform", + "humanize", + "Mako", + "markdown", + "openpyxl", + "paginate", + "paginate_sqlalchemy", + "passlib", + "Pillow", + "pyramid>=2", + "pyramid_beaker", + "pyramid_deform", + "pyramid_exclog", + "pyramid_fanstatic", + "pyramid_mako", + "pyramid_retry", + "pyramid_tm", + "rattail[db,bouncer]", + "sa-filters", + "simplejson", + "transaction", + "waitress", + "WebHelpers2", + "zope.sqlalchemy>=1.5", +] + + +[project.optional-dependencies] +docs = ["Sphinx", "sphinx-rtd-theme"] +tests = ["coverage", "mock", "pytest", "pytest-cov"] + + +[project.entry-points."paste.app_factory"] +main = "tailbone.app:main" +webapi = "tailbone.webapi:main" + + +[project.entry-points."rattail.cleaners"] +beaker = "tailbone.cleanup:BeakerCleaner" + + +[project.entry-points."rattail.config.extensions"] +tailbone = "tailbone.config:ConfigExtension" + + +[project.urls] +Homepage = "https://rattailproject.org" +Repository = "https://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] +version_provider = "pep621" +tag_format = "v$version" +update_changelog_on_bump = true + + +[tool.nosetests] +nocapture = 1 +cover-package = "tailbone" +cover-erase = 1 +cover-html = 1 +cover-html-dir = "htmlcov" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 1787343a..00000000 --- a/setup.cfg +++ /dev/null @@ -1,96 +0,0 @@ - -[metadata] -name = Tailbone -version = 0.11.10 -author = Lance Edgar -author_email = lance@edbob.org -url = http://rattailproject.org/ -license = GNU GPL v3 -description = Backoffice Web Application for Rattail -long_description = file: README.rst -classifiers = - Development Status :: 4 - Beta - Environment :: Web Environment - Framework :: Pyramid - Intended Audience :: Developers - License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) - Natural Language :: English - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Topic :: Internet :: WWW/HTTP - Topic :: Office/Business - Topic :: Software Development :: Libraries :: Python Modules - - -[options] -packages = find: -include_package_data = True -install_requires = - asgiref - colander - ColanderAlchemy - cornice - cornice-swagger - deform - humanize - Mako - markdown - openpyxl - paginate - paginate_sqlalchemy - passlib - Pillow - pyramid>=2 - pyramid_beaker - pyramid_deform - pyramid_exclog - pyramid_fanstatic - pyramid_mako - pyramid_retry - pyramid_tm - rattail[db,bouncer] - sa-filters - simplejson - transaction - waitress - WebHelpers2 - zope.sqlalchemy>=1.5 - - -[options.packages.find] -exclude = - tests.* - tests - - -[options.extras_require] -docs = Sphinx; sphinx-rtd-theme -tests = coverage; mock; pytest; pytest-cov - - -[options.entry_points] - -paste.app_factory = - main = tailbone.app:main - webapi = tailbone.webapi:main - -rattail.cleaners = - beaker = tailbone.cleanup:BeakerCleaner - -rattail.config.extensions = - tailbone = tailbone.config:ConfigExtension - - -[nosetests] -nocapture = 1 -cover-package = tailbone -cover-erase = 1 -cover-html = 1 -cover-html-dir = htmlcov diff --git a/setup.py b/setup.py deleted file mode 100644 index b908cbe5..00000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -import setuptools - -setuptools.setup() diff --git a/tox.ini b/tox.ini index 6e45883c..3896befb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,12 @@ [tox] -# TODO: i had to remove py36 since something (hatchling?) broke it -# somehow, and i was not able to quickly fix. as of writing only -# one app is known to run py36 and hopefully that is not for long. -envlist = py37, py38, py39, py310, py311 - -# TODO: can remove this when we drop py36 support -# nb. need this for testing older python versions -# https://tox.wiki/en/latest/faq.html#testing-end-of-life-python-versions -requires = virtualenv<20.22.0 +envlist = py38, py39, py310, py311 [testenv] deps = rattail-tempmon extras = tests commands = pytest {posargs} -[testenv:py37] -# nb. Chameleon 4.3 requires Python 3.9+ -deps = Chameleon<4.3 - [testenv:coverage] basepython = python3 extras = tests From 4eb58663798de21e34391c2298c12b53a7f2b4c5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 9 Jul 2024 16:45:45 -0500 Subject: [PATCH 057/211] =?UTF-8?q?bump:=20version=200.11.10=20=E2=86=92?= =?UTF-8?q?=200.12.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54b0e1de..e3832f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.12.0 (2024-07-09) + +### Feat + +- drop python 3.6 support, use pyproject.toml (again) + ## v0.11.10 (2024-07-05) ### Fix diff --git a/pyproject.toml b/pyproject.toml index bc4bb451..7b4cd713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.11.10" +version = "0.12.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From ae8212069c731a10cc342965711c562d6f1db603 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Jul 2024 13:16:02 -0500 Subject: [PATCH 058/211] fix: refactor `config.get_model()` => `app.model` per rattail changes --- pyproject.toml | 2 +- tailbone/forms/core.py | 3 ++- tailbone/forms/widgets.py | 18 ++++++++++++------ tailbone/grids/core.py | 12 +++++++----- tailbone/subscribers.py | 8 +++++--- tailbone/util.py | 2 +- tailbone/views/asgi/__init__.py | 7 ++++--- tailbone/views/core.py | 18 +++++++++--------- tailbone/views/custorders/batch.py | 18 +++++++++--------- tasks.py | 6 ++++-- 10 files changed, 54 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7b4cd713..3b2b3b6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]", + "rattail[db,bouncer]>=0.16.0", "sa-filters", "simplejson", "transaction", diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index d6303bb1..11d489a7 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -875,7 +875,8 @@ class Form(object): for field in self]) def get_field_markdowns(self): - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model if not hasattr(self, 'field_markdowns'): infos = Session.query(model.TailboneFieldInfo)\ diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 2923b7ec..8c16726d 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -477,7 +477,8 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): def __init__(self, request, *args, **kwargs): super().__init__(*args, **kwargs) self.request = request - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model # must figure out URL providing autocomplete service if 'service_url' not in kwargs: @@ -498,7 +499,8 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): """ """ # fetch customer to provide button label, if we have a value if cstruct: - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model customer = Session.get(model.Customer, cstruct) if customer: self.field_display = str(customer) @@ -552,7 +554,8 @@ class DepartmentWidget(dfwidget.SelectWidget): def __init__(self, request, **kwargs): if 'values' not in kwargs: - model = request.rattail_config.get_model() + app = request.rattail_config.get_app() + model = app.model departments = Session.query(model.Department)\ .order_by(model.Department.number) values = [(dept.uuid, str(dept)) @@ -594,7 +597,8 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): def __init__(self, request, *args, **kwargs): super().__init__(*args, **kwargs) self.request = request - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model # must figure out URL providing autocomplete service if 'service_url' not in kwargs: @@ -615,7 +619,8 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): """ """ # fetch vendor to provide button label, if we have a value if cstruct: - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model vendor = Session.get(model.Vendor, cstruct) if vendor: self.field_display = str(vendor) @@ -643,7 +648,8 @@ class VendorDropdownWidget(dfwidget.SelectWidget): vendors = vendors() else: # default vendor list - model = self.request.rattail_config.get_model() + app = self.request.rattail_config.get_app() + model = app.model vendors = Session.query(model.Vendor)\ .order_by(model.Vendor.name)\ .all() diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 91c3d1f5..b4610a18 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -32,7 +32,7 @@ import sqlalchemy as sa from sqlalchemy import orm from rattail.db.types import GPCType -from rattail.util import prettify, pretty_boolean, pretty_quantity +from rattail.util import prettify, pretty_boolean from pyramid.renderers import render from webhelpers2.html import HTML, tags @@ -60,7 +60,7 @@ class FieldList(list): self.insert(i + 1, newfield) -class Grid(object): +class Grid: """ Core grid class. In sore need of documentation. @@ -532,7 +532,8 @@ class Grid(object): def render_quantity(self, obj, column_name): value = self.obtain_value(obj, column_name) - return pretty_quantity(value) + app = self.request.rattail_config.get_app() + return app.render_quantity(value) def render_duration(self, obj, column_name): seconds = self.obtain_value(obj, column_name) @@ -1152,10 +1153,12 @@ class Grid(object): """ 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[k]): if to == 'defaults': skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) - app = self.request.rattail_config.get_app() app.save_setting(Session(), skey, value(key)) else: # to == session skey = 'grid.{}.{}'.format(self.key, key) @@ -1172,7 +1175,6 @@ class Grid(object): # first clear existing settings for *sorting* only # nb. this is because number of sort settings will vary if to == 'defaults': - model = self.request.rattail_config.get_model() prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' query = Session.query(model.Setting)\ .filter(sa.or_( diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index bd59a033..b02346a3 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -92,7 +92,8 @@ def new_request(event): user = None uuid = request.authenticated_userid if uuid: - model = request.rattail_config.get_model() + app = request.rattail_config.get_app() + model = app.model user = Session.get(model.User, uuid) if user: Session().set_continuum_user(user) @@ -174,7 +175,7 @@ def before_render(event): renderer_globals['url'] = request.route_url renderer_globals['rattail'] = rattail renderer_globals['tailbone'] = tailbone - renderer_globals['model'] = request.rattail_config.get_model() + renderer_globals['model'] = app.model renderer_globals['enum'] = request.rattail_config.get_enum() renderer_globals['json'] = json renderer_globals['datetime'] = datetime @@ -258,8 +259,9 @@ def add_inbox_count(event): request = event.get('request') or threadlocal.get_current_request() if request.user: renderer_globals = event + app = request.rattail_config.get_app() + model = app.model enum = request.rattail_config.get_enum() - model = request.rattail_config.get_model() renderer_globals['inbox_count'] = Session.query(model.Message)\ .outerjoin(model.MessageRecipient)\ .filter(model.MessageRecipient.recipient == Session.merge(request.user))\ diff --git a/tailbone/util.py b/tailbone/util.py index 98a7f7d4..c1a0e1d5 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -506,7 +506,7 @@ def include_configured_views(pyramid_config): """ rattail_config = pyramid_config.registry.settings.get('rattail_config') app = rattail_config.get_app() - model = rattail_config.get_model() + model = app.model session = app.make_session() # fetch all include-related settings at once diff --git a/tailbone/views/asgi/__init__.py b/tailbone/views/asgi/__init__.py index bebe16f3..33888654 100644 --- a/tailbone/views/asgi/__init__.py +++ b/tailbone/views/asgi/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -41,12 +41,13 @@ class MockRequest(dict): pass -class WebsocketView(object): +class WebsocketView: def __init__(self, pyramid_config): self.pyramid_config = pyramid_config self.registry = self.pyramid_config.registry - self.model = self.rattail_config.get_model() + app = self.get_rattail_app() + self.model = app.model @property def rattail_config(self): diff --git a/tailbone/views/core.py b/tailbone/views/core.py index 97b59c10..b0658d80 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -26,10 +26,6 @@ Base View Class import os -from rattail.db import model -from rattail.core import Object -from rattail.util import progress_loop - from pyramid import httpexceptions from pyramid.renderers import render_to_response from pyramid.response import FileResponse @@ -40,7 +36,7 @@ from tailbone.progress import SessionProgress from tailbone.config import protected_usernames -class View(object): +class View: """ Base class for all class-based views. """ @@ -62,8 +58,9 @@ class View(object): config = self.rattail_config if config: + app = config.get_app() + self.model = app.model self.enum = config.get_enum() - self.model = config.get_model() @property def rattail_config(self): @@ -94,6 +91,7 @@ class View(object): Returns the :class:`rattail:rattail.db.model.User` instance corresponding to the "late login" form data (if any), or ``None``. """ + model = self.model if self.request.method == 'POST': uuid = self.request.POST.get('late-login-user') if uuid: @@ -120,7 +118,8 @@ class View(object): return httpexceptions.HTTPFound(location=url, **kwargs) def progress_loop(self, func, items, factory, *args, **kwargs): - return progress_loop(func, items, factory, *args, **kwargs) + app = self.get_rattail_app() + return app.progress_loop(func, items, factory, *args, **kwargs) def make_progress(self, key, **kwargs): """ @@ -165,7 +164,8 @@ class View(object): return self.expose_quickie_search def get_quickie_context(self): - return Object( + app = self.get_rattail_app() + return app.make_object( url=self.get_quickie_url(), perm=self.get_quickie_perm(), placeholder=self.get_quickie_placeholder()) diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index 38d2eda7..fa0df901 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,7 +24,7 @@ Base class for customer order batch views """ -from rattail.db import model +from rattail.db.model import CustomerOrderBatch, CustomerOrderBatchRow import colander from webhelpers2.html import tags @@ -38,8 +38,8 @@ class CustomerOrderBatchView(BatchMasterView): Master view base class, for customer order batches. The views for the various mode/workflow batches will derive from this. """ - model_class = model.CustomerOrderBatch - model_row_class = model.CustomerOrderBatchRow + model_class = CustomerOrderBatch + model_row_class = CustomerOrderBatchRow default_handler_spec = 'rattail.batch.custorder:CustomerOrderBatchHandler' grid_columns = [ @@ -122,7 +122,7 @@ class CustomerOrderBatchView(BatchMasterView): ] def configure_grid(self, g): - super(CustomerOrderBatchView, self).configure_grid(g) + super().configure_grid(g) g.set_type('total_price', 'currency') @@ -131,9 +131,9 @@ class CustomerOrderBatchView(BatchMasterView): g.set_link('created_by') def configure_form(self, f): - super(CustomerOrderBatchView, self).configure_form(f) + super().configure_form(f) order = f.model_instance - model = self.rattail_config.get_model() + model = self.model # readonly fields f.set_readonly('rows') @@ -201,7 +201,7 @@ class CustomerOrderBatchView(BatchMasterView): return 'notice' def configure_row_grid(self, g): - super(CustomerOrderBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) g.set_type('case_quantity', 'quantity') g.set_type('cases_ordered', 'quantity') @@ -215,7 +215,7 @@ class CustomerOrderBatchView(BatchMasterView): g.set_link('product_description') def configure_row_form(self, f): - super(CustomerOrderBatchView, self).configure_row_form(f) + super().configure_row_form(f) f.set_renderer('product', self.render_product) f.set_renderer('pending_product', self.render_pending_product) diff --git a/tasks.py b/tasks.py index b57315a0..4ca01bab 100644 --- a/tasks.py +++ b/tasks.py @@ -31,16 +31,18 @@ from invoke import task @task -def release(c, tests=False): +def release(c, skip_tests=False): """ Release a new version of 'Tailbone'. """ - if tests: + if not skip_tests: c.run('tox') if os.path.exists('dist'): shutil.rmtree('dist') if os.path.exists('Tailbone.egg-info'): shutil.rmtree('Tailbone.egg-info') + c.run('python -m build --sdist') + c.run('twine upload dist/*') From 09ce2d5a40af7204621a921cdb8c448d45f0c5ec Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Jul 2024 13:16:36 -0500 Subject: [PATCH 059/211] =?UTF-8?q?bump:=20version=200.12.0=20=E2=86=92=20?= =?UTF-8?q?0.12.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3832f0f..dfeabd92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.12.1 (2024-07-11) + +### Fix + +- refactor `config.get_model()` => `app.model` + ## v0.12.0 (2024-07-09) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 3b2b3b6d..847b5e28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.12.0" +version = "0.12.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From e531f98079c7d5a04ef8a003686748e5b1a3cf82 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 11 Jul 2024 13:54:37 -0500 Subject: [PATCH 060/211] fix: cast enum as list to satisfy deform widget seems to only be an issue for deform 2.0.15+ --- tailbone/views/batch/handheld.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/handheld.py b/tailbone/views/batch/handheld.py index eb22f367..486d8774 100644 --- a/tailbone/views/batch/handheld.py +++ b/tailbone/views/batch/handheld.py @@ -46,7 +46,7 @@ class ExecutionOptions(colander.Schema): action = colander.SchemaNode( colander.String(), validator=colander.OneOf(ACTION_OPTIONS), - widget=dfwidget.SelectWidget(values=ACTION_OPTIONS.items())) + widget=dfwidget.SelectWidget(values=list(ACTION_OPTIONS.items()))) class HandheldBatchView(FileBatchMasterView): From ce156d6278b1b243a714499315c665ca49760fbe Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 12 Jul 2024 09:35:34 -0500 Subject: [PATCH 061/211] feat: begin integrating WuttaWeb as upstream dependency the bare minimum, just to get the relationship established. mostly it's calling upstream subscriber / event hooks where applicable. this also overhauls the docs config to use furo theme etc. --- docs/api/subscribers.rst | 3 +- docs/conf.py | 276 ++++----------------------------------- pyproject.toml | 3 +- tailbone/app.py | 7 +- tailbone/config.py | 3 + tailbone/subscribers.py | 138 +++++++++++--------- 6 files changed, 112 insertions(+), 318 deletions(-) diff --git a/docs/api/subscribers.rst b/docs/api/subscribers.rst index 8b25c994..d28a1b15 100644 --- a/docs/api/subscribers.rst +++ b/docs/api/subscribers.rst @@ -3,5 +3,4 @@ ======================== .. automodule:: tailbone.subscribers - -.. autofunction:: new_request + :members: diff --git a/docs/conf.py b/docs/conf.py index 505396ed..52e384f5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,38 +1,21 @@ -# -*- coding: utf-8; -*- +# Configuration file for the Sphinx documentation builder. # -# Tailbone documentation build configuration file, created by -# sphinx-quickstart on Sat Feb 15 23:15:27 2014. -# -# This file is exec()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html -import sys -import os +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -import sphinx_rtd_theme +from importlib.metadata import version as get_version -exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read()) +project = 'Tailbone' +copyright = '2010 - 2024, Lance Edgar' +author = 'Lance Edgar' +release = get_version('Tailbone') +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.todo', @@ -40,241 +23,30 @@ extensions = [ 'sphinx.ext.viewcode', ] +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + intersphinx_mapping = { 'rattail': ('https://rattailproject.org/docs/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), + 'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None), + 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), } -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'Tailbone' -copyright = u'2010 - 2020, Lance Edgar' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -# version = '0.3' -version = '.'.join(__version__.split('.')[:2]) -# The full version, including alpha/beta/rc tags. -release = __version__ - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# Allow todo entries to show up. +# allow todo entries to show up todo_include_todos = True -# -- Options for HTML output ---------------------------------------------- +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# html_theme = 'classic' -html_theme = 'sphinx_rtd_theme' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - -# The name for this set of Sphinx documents. If None, it defaults to -# "<project> v<release> documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +html_theme = 'furo' +html_static_path = ['_static'] # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None -html_logo = 'images/rattail_avatar.png' - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a <link> tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +#html_logo = 'images/rattail_avatar.png' # Output file base name for HTML help builder. -htmlhelp_basename = 'Tailbonedoc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ('index', 'Tailbone.tex', u'Tailbone Documentation', - u'Lance Edgar', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'tailbone', u'Tailbone Documentation', - [u'Lance Edgar'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'Tailbone', u'Tailbone Documentation', - u'Lance Edgar', 'Tailbone', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +#htmlhelp_basename = 'Tailbonedoc' diff --git a/pyproject.toml b/pyproject.toml index 847b5e28..defb1ffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,12 +59,13 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", + "WuttaWeb", "zope.sqlalchemy>=1.5", ] [project.optional-dependencies] -docs = ["Sphinx", "sphinx-rtd-theme"] +docs = ["Sphinx", "furo"] tests = ["coverage", "mock", "pytest", "pytest-cov"] diff --git a/tailbone/app.py b/tailbone/app.py index b0160bd3..b7220703 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -30,7 +30,9 @@ import warnings import sqlalchemy as sa from sqlalchemy.orm import sessionmaker, scoped_session -from rattail.config import make_config, parse_list +from wuttjamaican.util import parse_list + +from rattail.config import make_config from rattail.exceptions import ConfigurationError from rattail.db.types import GPCType @@ -61,6 +63,9 @@ def make_rattail_config(settings): rattail_config = make_config(path) settings['rattail_config'] = rattail_config + # nb. this is for compaibility with wuttaweb + settings['wutta_config'] = rattail_config + # configure database sessions if hasattr(rattail_config, 'rattail_engine'): tailbone.db.Session.configure(bind=rattail_config.rattail_engine) diff --git a/tailbone/config.py b/tailbone/config.py index ee906149..ce1691ae 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -52,6 +52,9 @@ class ConfigExtension(BaseExtension): config.setdefault('tailbone', 'themes.keys', 'default, butterball') config.setdefault('tailbone', 'themes.expose_picker', 'true') + # override oruga detection + config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga') + def csrf_token_name(config): return config.get('tailbone', 'csrf_token_name', default='_csrf') diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index b02346a3..0bf218cb 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -24,7 +24,6 @@ Event Subscribers """ -import json import datetime import logging import warnings @@ -37,13 +36,14 @@ import deform from pyramid import threadlocal from webhelpers2.html import tags +from wuttaweb import subscribers as base + import tailbone from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name, should_expose_websockets from tailbone.menus import make_simple_menus -from tailbone.util import (get_available_themes, get_global_search_options, - should_use_oruga) +from tailbone.util import get_available_themes, get_global_search_options log = logging.getLogger(__name__) @@ -51,42 +51,59 @@ log = logging.getLogger(__name__) def new_request(event): """ - Identify the current user, and cache their current permissions. Also adds - the ``rattail_config`` attribute to the request. + Event hook called when processing a new request. - A global Rattail ``config`` should already be present within the Pyramid - application registry's settings, which would normally be accessed via:: - - request.registry.settings['rattail_config'] + This first invokes the upstream hook: + :func:`wuttaweb:wuttaweb.subscribers.new_request()` - This function merely "promotes" that config object so that it is more - directly accessible, a la:: + It then adds more things to the request object; among them: - request.rattail_config + .. attribute:: request.rattail_config - .. note:: - This of course assumes that a Rattail ``config`` object *has* in fact - already been placed in the application registry settings. If this is - not the case, this function will do nothing. + Reference to the app :term:`config object`. Note that this + will be the same as ``request.wutta_config``. - Also, attach some goodies to the request object: + .. attribute:: request.user - * The currently logged-in user instance (if any), as ``user``. + Reference to the current authenticated user, or ``None``. - * ``is_admin`` flag indicating whether user has the Administrator role. + .. attribute:: request.is_admin - * ``is_root`` flag indicating whether user is currently elevated to root. + Flag indicating whether current user is a member of the + Administrator role. - * A shortcut method for permission checking, as ``has_perm()``. + .. 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) + + Function to register a Vue component for use with the app. + + This can be called from wherever a component is defined, and + then in the base template all registered components will be + properly loaded. """ - log.debug("new request: %s", event) + # log.debug("new request: %s", event) request = event.request - rattail_config = request.registry.settings.get('rattail_config') - # TODO: why would this ever be null? - if rattail_config: - request.rattail_config = rattail_config - else: - log.error("registry has no rattail_config ?!") + + # invoke upstream logic + base.new_request(event) + + # compatibility + rattail_config = request.wutta_config + request.rattail_config = rattail_config def user(request): user = None @@ -101,15 +118,6 @@ def new_request(event): request.set_property(user, reify=True) - # nb. only add oruga check for "classic" web app - classic = rattail_config.parse_bool(request.registry.settings.get('tailbone.classic')) - if classic: - - def use_oruga(request): - return should_use_oruga(request) - - request.set_property(use_oruga, reify=True) - # assign client IP address to the session, for sake of versioning Session().continuum_remote_addr = request.client_addr @@ -161,27 +169,34 @@ def before_render(event): """ Adds goodies to the global template renderer context. """ - log.debug("before_render: %s", event) + # log.debug("before_render: %s", event) + + # invoke upstream logic + base.before_render(event) request = event.get('request') or threadlocal.get_current_request() - rattail_config = request.rattail_config - app = rattail_config.get_app() + config = request.wutta_config + app = config.get_app() renderer_globals = event - renderer_globals['rattail_app'] = app - renderer_globals['app_title'] = app.get_title() - renderer_globals['app_version'] = app.get_version() + + # wuttaweb overrides renderer_globals['h'] = helpers - renderer_globals['url'] = request.route_url - renderer_globals['rattail'] = rattail - renderer_globals['tailbone'] = tailbone - renderer_globals['model'] = app.model - renderer_globals['enum'] = request.rattail_config.get_enum() - renderer_globals['json'] = json + + # misc. renderer_globals['datetime'] = datetime renderer_globals['colander'] = colander renderer_globals['deform'] = deform - renderer_globals['csrf_header_name'] = csrf_header_name(request.rattail_config) + renderer_globals['csrf_header_name'] = csrf_header_name(config) + + # TODO: deprecate / remove these + renderer_globals['rattail_app'] = app + renderer_globals['app_title'] = app.get_title() + renderer_globals['app_version'] = app.get_version() + renderer_globals['rattail'] = rattail + renderer_globals['tailbone'] = tailbone + renderer_globals['model'] = app.model + renderer_globals['enum'] = app.enum # theme - we only want do this for classic web app, *not* API # TODO: so, clearly we need a better way to distinguish the two @@ -189,13 +204,13 @@ def before_render(event): 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 = request.rattail_config.getbool('tailbone', 'themes.expose_picker', - default=False) + expose_picker = config.get_bool('tailbone.themes.expose_picker', + default=False) renderer_globals['expose_theme_picker'] = expose_picker if expose_picker: # TODO: should remove 'falafel' option altogether - available = get_available_themes(request.rattail_config) + available = get_available_themes(config) options = [tags.Option(theme, value=theme) for theme in available] renderer_globals['theme_picker_options'] = options @@ -204,26 +219,25 @@ def before_render(event): # (we don't want this to happen for the API either!) # TODO: just..awful *shrug* # note that we assume "simple" menus nowadays - if request.rattail_config.getbool('tailbone', 'menus.simple', default=True): + if config.get_bool('tailbone.menus.simple', default=True): renderer_globals['menus'] = make_simple_menus(request) # TODO: ugh, same deal here - renderer_globals['messaging_enabled'] = request.rattail_config.getbool( - 'tailbone', 'messaging.enabled', default=False) + renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled', + default=False) # background color may be set per-request, by some apps if hasattr(request, 'background_color') and request.background_color: renderer_globals['background_color'] = request.background_color else: # otherwise we use the one from config - renderer_globals['background_color'] = request.rattail_config.get( - 'tailbone', 'background_color') + renderer_globals['background_color'] = config.get('tailbone.background_color') # maybe set custom stylesheet css = None if request.user: - css = rattail_config.get(f'tailbone.{request.user.uuid}', 'user_css') + css = config.get(f'tailbone.{request.user.uuid}', 'user_css') if not css: - css = rattail_config.get(f'tailbone.{request.user.uuid}', 'buefy_css') + css = config.get(f'tailbone.{request.user.uuid}', 'buefy_css') if css: warnings.warn(f"setting 'tailbone.{request.user.uuid}.buefy_css' should be" f"changed to 'tailbone.{request.user.uuid}.user_css'", @@ -234,7 +248,7 @@ def before_render(event): renderer_globals['global_search_data'] = get_global_search_options(request) # here we globally declare widths for grid filter pseudo-columns - widths = request.rattail_config.get('tailbone', 'grids.filters.column_widths') + widths = config.get('tailbone.grids.filters.column_widths') if widths: widths = widths.split(';') if len(widths) < 2: @@ -245,7 +259,7 @@ def before_render(event): renderer_globals['filter_verb_width'] = widths[1] # declare global support for websockets, or lack thereof - renderer_globals['expose_websockets'] = should_expose_websockets(rattail_config) + renderer_globals['expose_websockets'] = should_expose_websockets(config) def add_inbox_count(event): From ca660f408712344683121aea37f7d937f26c6fbc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 12 Jul 2024 09:38:12 -0500 Subject: [PATCH 062/211] =?UTF-8?q?bump:=20version=200.12.1=20=E2=86=92=20?= =?UTF-8?q?0.13.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfeabd92..92e849f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.13.0 (2024-07-12) + +### Feat + +- begin integrating WuttaWeb as upstream dependency + +### Fix + +- cast enum as list to satisfy deform widget + ## v0.12.1 (2024-07-11) ### Fix diff --git a/pyproject.toml b/pyproject.toml index defb1ffe..396ba8dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.12.1" +version = "0.13.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From ee781ec48984a4d159fddf805dd88e513a4aad6e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Jul 2024 15:14:04 -0500 Subject: [PATCH 063/211] fix: fix settings persistence bug(s) for datasync/configure page also hide the Changes context menu link, within the Configure page --- tailbone/templates/datasync/configure.mako | 61 ++++++++++++++++------ tailbone/views/datasync.py | 10 ++-- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index a512745c..04eda0fb 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -1,6 +1,15 @@ ## -*- coding: utf-8; -*- <%inherit file="/configure.mako" /> +<%def name="extra_styles()"> + ${parent.extra_styles()} + <style> + .invisible-watcher { + display: none; + } + </style> +</%def> + <%def name="buttons_row()"> <div class="level"> <div class="level-left"> @@ -106,8 +115,8 @@ </div> </div> - <${b}-table :data="filteredProfilesData" - :row-class="(row, i) => row.enabled ? null : 'has-background-warning'"> + <${b}-table :data="profilesData" + :row-class="getWatcherRowClass"> <${b}-table-column field="key" label="Watcher Key" v-slot="props"> @@ -625,19 +634,6 @@ ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n} ThisPageData.restartCommand = ${json.dumps(restart_command)|n} - ThisPage.computed.filteredProfilesData = function() { - if (this.showDisabledProfiles) { - return this.profilesData - } - let data = [] - for (let row of this.profilesData) { - if (row.enabled) { - data.push(row) - } - } - return data - } - ThisPage.computed.updateConsumerDisabled = function() { if (!this.editingConsumerKey) { return true @@ -665,6 +661,15 @@ this.showDisabledProfiles = !this.showDisabledProfiles } + ThisPage.methods.getWatcherRowClass = function(row, i) { + if (!row.enabled) { + if (!this.showDisabledProfiles) { + return 'invisible-watcher' + } + return 'has-background-warning' + } + } + ThisPage.methods.consumerShortList = function(row) { let keys = [] if (row.watcher_consumes_self) { @@ -795,9 +800,10 @@ } ThisPage.methods.updateProfile = function() { - let row = this.editingProfile + const row = this.editingProfile - if (!row.key) { + const newRow = !row.key + if (newRow) { row.consumers_data = [] this.profilesData.push(row) } @@ -874,10 +880,31 @@ row.consumers_data.splice(i, 1) } + if (newRow) { + + // nb. must explicitly update the original data row; + // otherwise (with vue3) it will remain stale and + // submitting the form will keep same settings! + // TODO: this probably means i am doing something + // sloppy, but at least this hack fixes for now. + const profile = this.findProfile(row) + for (const key of Object.keys(row)) { + profile[key] = row[key] + } + } + this.settingsNeedSaved = true this.editProfileShowDialog = false } + ThisPage.methods.findProfile = function(row) { + for (const profile of this.profilesData) { + if (profile.key == row.key) { + return profile + } + } + } + ThisPage.methods.deleteProfile = function(row) { if (confirm("Are you sure you want to delete the '" + row.key + "' profile?")) { let i = this.profilesData.indexOf(row) diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 7616d288..134d6018 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -79,11 +79,13 @@ class DataSyncThreadView(MasterView): def get_context_menu_items(self, thread=None): items = super().get_context_menu_items(thread) + route_prefix = self.get_route_prefix() - # nb. just one view here, no need to check if listing etc. - if self.request.has_perm('datasync_changes.list'): - url = self.request.route_url('datasyncchanges') - items.append(tags.link_to("View DataSync Changes", url)) + # nb. do not show this for /configure page + if self.request.matched_route.name != f'{route_prefix}.configure': + if self.request.has_perm('datasync_changes.list'): + url = self.request.route_url('datasyncchanges') + items.append(tags.link_to("View DataSync Changes", url)) return items From eede274529db7eaa93a4ab6f4d920d63d2297cde Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Jul 2024 15:15:51 -0500 Subject: [PATCH 064/211] =?UTF-8?q?bump:=20version=200.13.0=20=E2=86=92=20?= =?UTF-8?q?0.13.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e849f8..c766025a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.13.1 (2024-07-13) + +### Fix + +- fix settings persistence bug(s) for datasync/configure page + ## v0.13.0 (2024-07-12) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 396ba8dd..c15e8073 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.13.0" +version = "0.13.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From d2d0206b4503e8d6b525df5e737cf24421591493 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Jul 2024 15:16:45 -0500 Subject: [PATCH 065/211] build: run `pytest` but avoid `tox` when preparing release buildbot can let us know if something goes wrong with an atypical python version etc. --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 4ca01bab..6983dbea 100644 --- a/tasks.py +++ b/tasks.py @@ -36,7 +36,7 @@ def release(c, skip_tests=False): Release a new version of 'Tailbone'. """ if not skip_tests: - c.run('tox') + c.run('pytest') if os.path.exists('dist'): shutil.rmtree('dist') From 27214cc62f781aa271f1645ad8c5dac6f3924d4d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Jul 2024 15:28:28 -0500 Subject: [PATCH 066/211] fix: fix logic bug for datasync/config settings save dang it --- tailbone/templates/datasync/configure.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 04eda0fb..0889b144 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -880,7 +880,7 @@ row.consumers_data.splice(i, 1) } - if (newRow) { + if (!newRow) { // nb. must explicitly update the original data row; // otherwise (with vue3) it will remain stale and From 0b4629ea29edb56abfcfb21e9bc99673143baca8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 13 Jul 2024 15:28:59 -0500 Subject: [PATCH 067/211] =?UTF-8?q?bump:=20version=200.13.1=20=E2=86=92=20?= =?UTF-8?q?0.13.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c766025a..40604948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.13.2 (2024-07-13) + +### Fix + +- fix logic bug for datasync/config settings save + ## v0.13.1 (2024-07-13) ### Fix diff --git a/pyproject.toml b/pyproject.toml index c15e8073..12f6a538 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.13.1" +version = "0.13.2" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From fd1ec01128438fffc78996cf6b4f367f48de7f41 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 10:52:32 -0500 Subject: [PATCH 068/211] feat: move core menu logic to wuttaweb tailbone still defines the default menus, and allows for making dynamic menus from config (which wuttaweb does not). also remove some even older logic for "v1" menu functions --- tailbone/handler.py | 13 ++- tailbone/menus.py | 232 +++++++--------------------------------- tailbone/subscribers.py | 10 +- tailbone/views/menus.py | 11 +- 4 files changed, 54 insertions(+), 212 deletions(-) diff --git a/tailbone/handler.py b/tailbone/handler.py index 22f33cca..00f41bc9 100644 --- a/tailbone/handler.py +++ b/tailbone/handler.py @@ -24,6 +24,8 @@ Tailbone Handler """ +import warnings + from mako.lookup import TemplateLookup from rattail.app import GenericHandler @@ -46,11 +48,14 @@ class TailboneHandler(GenericHandler): def get_menu_handler(self, **kwargs): """ - Get the configured "menu" handler. - - :returns: The :class:`~tailbone.menus.MenuHandler` instance - for the app. + DEPRECATED; use + :meth:`wuttaweb.handler.WebHandler.get_menu_handler()` + instead. """ + warnings.warn("TailboneHandler.get_menu_handler() is deprecated; " + "please use WebHandler.get_menu_handler() instead", + DeprecationWarning, stacklevel=2) + if not hasattr(self, 'menu_handler'): spec = self.config.get('tailbone.menus', 'handler', default='tailbone.menus:MenuHandler') diff --git a/tailbone/menus.py b/tailbone/menus.py index 50dd3f4a..0752c22d 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,37 +24,48 @@ App Menus """ -import re import logging import warnings -from rattail.app import GenericHandler from rattail.util import prettify, simple_error from webhelpers2.html import tags, HTML +from wuttaweb.menus import MenuHandler as WuttaMenuHandler + from tailbone.db import Session log = logging.getLogger(__name__) -class MenuHandler(GenericHandler): +class TailboneMenuHandler(WuttaMenuHandler): """ Base class and default implementation for menu handler. """ - def make_raw_menus(self, request, **kwargs): - """ - Generate a full set of "raw" menus for the app. + ############################## + # internal methods + ############################## - The "raw" menus are basically just a set of dicts to represent - the final menus. + def _is_allowed(self, request, item): + """ + TODO: must override this until wuttaweb has proper user auth checks + """ + perm = item.get('perm') + if perm: + return request.has_perm(perm) + return True + + def _make_raw_menus(self, request, **kwargs): + """ + We are overriding this to allow for making dynamic menus from + config/settings. Which may or may not be a good idea.. """ # first try to make menus from config, but this is highly # susceptible to failure, so try to warn user of problems try: - menus = self.make_menus_from_config(request) + menus = self._make_menus_from_config(request) if menus: return menus except Exception as error: @@ -71,9 +82,9 @@ class MenuHandler(GenericHandler): request.session.flash(msg, 'warning') # okay, no config, so menus will be built from code - return self.make_menus(request) + return self.make_menus(request, **kwargs) - def make_menus_from_config(self, request, **kwargs): + def _make_menus_from_config(self, request, **kwargs): """ Try to build a complete menu set from config/settings. @@ -101,16 +112,15 @@ class MenuHandler(GenericHandler): query=query, key='name', normalizer=lambda s: s.value) for key in main_keys: - menus.append(self.make_single_menu_from_settings(request, key, - settings)) + menus.append(self._make_single_menu_from_settings(request, key, settings)) else: # read from config file only for key in main_keys: - menus.append(self.make_single_menu_from_config(request, key)) + menus.append(self._make_single_menu_from_config(request, key)) return menus - def make_single_menu_from_config(self, request, key, **kwargs): + def _make_single_menu_from_config(self, request, key, **kwargs): """ Makes a single top-level menu dict from config file. Note that this will read from config file(s) *only* and avoids @@ -178,7 +188,7 @@ class MenuHandler(GenericHandler): return menu - def make_single_menu_from_settings(self, request, key, settings, **kwargs): + def _make_single_menu_from_settings(self, request, key, settings, **kwargs): """ Makes a single top-level menu dict from DB settings. """ @@ -237,6 +247,10 @@ class MenuHandler(GenericHandler): return menu + ############################## + # menu defaults + ############################## + def make_menus(self, request, **kwargs): """ Make the full set of menus for the app. @@ -723,182 +737,10 @@ class MenuHandler(GenericHandler): } -def make_simple_menus(request): - """ - Build the main menu list for the app. - """ - app = request.rattail_config.get_app() - tailbone_handler = app.get_tailbone_handler() - menu_handler = tailbone_handler.get_menu_handler() +class MenuHandler(TailboneMenuHandler): - raw_menus = menu_handler.make_raw_menus(request) - - # now we have "simple" (raw) menus definition, but must refine - # that somewhat to produce our final menus - mark_allowed(request, raw_menus) - final_menus = [] - for topitem in raw_menus: - - if topitem['allowed']: - - if topitem.get('type') == 'link': - final_menus.append(make_menu_entry(request, topitem)) - - else: # assuming 'menu' type - - menu_items = [] - for item in topitem['items']: - if not item['allowed']: - continue - - # nested submenu - if item.get('type') == 'menu': - submenu_items = [] - for subitem in item['items']: - if subitem['allowed']: - submenu_items.append(make_menu_entry(request, subitem)) - menu_items.append({ - 'type': 'submenu', - 'title': item['title'], - 'items': submenu_items, - 'is_menu': True, - 'is_sep': False, - }) - - elif item.get('type') == 'sep': - # we only want to add a sep, *if* we already have some - # menu items (i.e. there is something to separate) - # *and* the last menu item is not a sep (avoid doubles) - if menu_items and not menu_items[-1]['is_sep']: - menu_items.append(make_menu_entry(request, item)) - - else: # standard menu item - menu_items.append(make_menu_entry(request, item)) - - # remove final separator if present - if menu_items and menu_items[-1]['is_sep']: - menu_items.pop() - - # only add if we wound up with something - assert menu_items - if menu_items: - group = { - 'type': 'menu', - 'key': topitem.get('key'), - 'title': topitem['title'], - 'items': menu_items, - 'is_menu': True, - 'is_link': False, - } - - # topitem w/ no key likely means it did not come - # from config but rather explicit definition in - # code. so we are free to "invent" a (safe) key - # for it, since that is only for editing config - if not group['key']: - group['key'] = make_menu_key(request.rattail_config, - topitem['title']) - - final_menus.append(group) - - return final_menus - - -def make_menu_key(config, value): - """ - Generate a normalized menu key for the given value. - """ - return re.sub(r'\W', '', value.lower()) - - -def make_menu_entry(request, item): - """ - Convert a simple menu entry dict, into a proper menu-related object, for - use in constructing final menu. - """ - # separator - if item.get('type') == 'sep': - return { - 'type': 'sep', - 'is_menu': False, - 'is_sep': True, - } - - # standard menu item - entry = { - 'type': 'item', - 'title': item['title'], - 'perm': item.get('perm'), - 'target': item.get('target'), - 'is_link': True, - 'is_menu': False, - 'is_sep': False, - } - if item.get('route'): - entry['route'] = item['route'] - try: - entry['url'] = request.route_url(entry['route']) - except KeyError: # happens if no such route - log.warning("invalid route name for menu entry: %s", entry) - entry['url'] = entry['route'] - entry['key'] = entry['route'] - else: - if item.get('url'): - entry['url'] = item['url'] - entry['key'] = make_menu_key(request.rattail_config, entry['title']) - return entry - - -def is_allowed(request, item): - """ - Logic to determine if a given menu item is "allowed" for current user. - """ - perm = item.get('perm') - if perm: - return request.has_perm(perm) - return True - - -def mark_allowed(request, menus): - """ - Traverse the menu set, and mark each item as "allowed" (or not) based on - current user permissions. - """ - for topitem in menus: - - if topitem.get('type', 'menu') == 'menu': - topitem['allowed'] = False - - for item in topitem['items']: - - if item.get('type') == 'menu': - for subitem in item['items']: - subitem['allowed'] = is_allowed(request, subitem) - - item['allowed'] = False - for subitem in item['items']: - if subitem['allowed'] and subitem.get('type') != 'sep': - item['allowed'] = True - break - - else: - item['allowed'] = is_allowed(request, item) - - for item in topitem['items']: - if item['allowed'] and item.get('type') != 'sep': - topitem['allowed'] = True - break - - -def make_admin_menu(request, **kwargs): - """ - Generate a typical Admin menu - """ - warnings.warn("make_admin_menu() function is deprecated; please use " - "MenuHandler.make_admin_menu() instead", - DeprecationWarning, stacklevel=2) - - app = request.rattail_config.get_app() - tailbone_handler = app.get_tailbone_handler() - menu_handler = tailbone_handler.get_menu_handler() - return menu_handler.make_admin_menu(request, **kwargs) + def __init__(self, *args, **kwargs): + warnings.warn("tailbone.menus.MenuHandler is deprecated; " + "please use tailbone.menus.TailboneMenuHandler instead", + DeprecationWarning, stacklevel=2) + super().__init__(*args, **kwargs) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 0bf218cb..12e1e32a 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -42,7 +42,6 @@ import tailbone from tailbone import helpers from tailbone.db import Session from tailbone.config import csrf_header_name, should_expose_websockets -from tailbone.menus import make_simple_menus from tailbone.util import get_available_themes, get_global_search_options @@ -180,7 +179,7 @@ def before_render(event): renderer_globals = event - # wuttaweb overrides + # overrides renderer_globals['h'] = helpers # misc. @@ -215,13 +214,6 @@ def before_render(event): options = [tags.Option(theme, value=theme) for theme in available] renderer_globals['theme_picker_options'] = options - # heck while we're assuming the classic web app here... - # (we don't want this to happen for the API either!) - # TODO: just..awful *shrug* - # note that we assume "simple" menus nowadays - if config.get_bool('tailbone.menus.simple', default=True): - renderer_globals['menus'] = make_simple_menus(request) - # TODO: ugh, same deal here renderer_globals['messaging_enabled'] = config.get_bool('tailbone.messaging.enabled', default=False) diff --git a/tailbone/views/menus.py b/tailbone/views/menus.py index f60ad274..b606e4e7 100644 --- a/tailbone/views/menus.py +++ b/tailbone/views/menus.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -30,7 +30,6 @@ import sqlalchemy as sa from tailbone.views import View from tailbone.db import Session -from tailbone.menus import make_menu_key class MenuConfigView(View): @@ -79,12 +78,16 @@ class MenuConfigView(View): return context def configure_gather_settings(self, data): + app = self.get_rattail_app() + web = app.get_web_handler() + menus = web.get_menu_handler() + settings = [{'name': 'tailbone.menu.from_settings', 'value': 'true'}] main_keys = [] for topitem in json.loads(data['menus']): - key = make_menu_key(self.rattail_config, topitem['title']) + key = menus._make_menu_key(self.rattail_config, topitem['title']) main_keys.append(key) settings.extend([ @@ -99,7 +102,7 @@ class MenuConfigView(View): if item.get('route'): item_key = item['route'] else: - item_key = make_menu_key(self.rattail_config, item['title']) + item_key = menus._make_menu_key(self.rattail_config, item['title']) item_keys.append(item_key) settings.extend([ From d70bac74f099e7f53bc9fe98d3d63e995aeed909 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 11:11:44 -0500 Subject: [PATCH 069/211] =?UTF-8?q?bump:=20version=200.13.2=20=E2=86=92=20?= =?UTF-8?q?0.14.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40604948..4c5304d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.14.0 (2024-07-14) + +### Feat + +- move core menu logic to wuttaweb + ## v0.13.2 (2024-07-13) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 12f6a538..de65655a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.13.2" +version = "0.14.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb", + "WuttaWeb>=0.2.0", "zope.sqlalchemy>=1.5", ] From 25e62fe6ef06ae2c9366e6f0c9c4445771e5bb16 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 11:47:15 -0500 Subject: [PATCH 070/211] fix: fix bug when making "integration" menus per recent refactor --- tailbone/menus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 0752c22d..9048ae43 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -281,8 +281,9 @@ class TailboneMenuHandler(WuttaMenuHandler): """ Make a set of menus for all registered system integrations. """ + tb = self.app.get_tailbone_handler() menus = [] - for provider in self.tb.iter_providers(): + for provider in tb.iter_providers(): menu = provider.make_integration_menu(request) if menu: menus.append(menu) From 5e1c0a5187ab5ab33a63f38cbb0c9da4a7a1f786 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 12:41:08 -0500 Subject: [PATCH 071/211] fix: fix model reference in menu handler --- tailbone/menus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 9048ae43..84c12343 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -96,7 +96,7 @@ class TailboneMenuHandler(WuttaMenuHandler): if not main_keys: return - model = self.model + model = self.app.model menus = [] # menu definition can come either from config file or db From ece29d7b6cfeb193e0fe7ee66a238f6dedba1144 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 23:29:17 -0500 Subject: [PATCH 072/211] fix: update usage of auth handler, per rattail changes --- pyproject.toml | 2 +- tailbone/api/core.py | 4 ++-- tailbone/auth.py | 14 +++++++++----- tailbone/subscribers.py | 9 +++++++-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de65655a..22fa5676 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.16.0", + "rattail[db,bouncer]>=0.17.0", "sa-filters", "simplejson", "transaction", diff --git a/tailbone/api/core.py b/tailbone/api/core.py index b278d4af..0d8eec32 100644 --- a/tailbone/api/core.py +++ b/tailbone/api/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -102,7 +102,7 @@ class APIView(View): auth = app.get_auth_handler() # basic / default info - is_admin = user.is_admin() + is_admin = auth.user_is_admin(user) employee = app.get_employee(user) info = { 'uuid': user.uuid, diff --git a/tailbone/auth.py b/tailbone/auth.py index 5a35caa6..826c5d40 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -45,11 +45,12 @@ 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. """ - app = request.rattail_config.get_app() + config = request.rattail_config + app = config.get_app() user.record_event(app.enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) if timeout is NOTSET: - timeout = session_timeout_for_user(user) + timeout = session_timeout_for_user(config, user) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) set_session_timeout(request, timeout) return headers @@ -70,15 +71,18 @@ def logout_user(request): return headers -def session_timeout_for_user(user): +def session_timeout_for_user(config, user): """ Returns the "max" session timeout for the user, according to roles """ - from rattail.db.auth import authenticated_role + app = config.get_app() + auth = app.get_auth_handler() - roles = user.roles + [authenticated_role(Session())] + authenticated = auth.get_role_authenticated(Session()) + roles = user.roles + [authenticated] timeouts = [role.session_timeout for role in roles if role.session_timeout is not None] + if timeouts and 0 not in timeouts: return max(timeouts) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 12e1e32a..181c84bc 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -98,10 +98,15 @@ def new_request(event): request = event.request # 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() + # compatibility - rattail_config = request.wutta_config + rattail_config = config request.rattail_config = rattail_config def user(request): @@ -120,7 +125,7 @@ def new_request(event): # assign client IP address to the session, for sake of versioning Session().continuum_remote_addr = request.client_addr - request.is_admin = bool(request.user) and request.user.is_admin() + request.is_admin = auth.user_is_admin(request.user) request.is_root = request.is_admin and request.session.get('is_root', False) # TODO: why would this ever be null? From 57fdacdb834dabab7bd61d1d492bc2c2d41d42dd Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 14 Jul 2024 23:29:35 -0500 Subject: [PATCH 073/211] =?UTF-8?q?bump:=20version=200.14.0=20=E2=86=92=20?= =?UTF-8?q?0.14.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5304d6..df38a20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.14.1 (2024-07-14) + +### Fix + +- update usage of auth handler, per rattail changes +- fix model reference in menu handler +- fix bug when making "integration" menus + ## v0.14.0 (2024-07-14) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 22fa5676..d7fa1c95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.0" +version = "0.14.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From be6eb5f8153e373772278f2786aa72b0c15f8daf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 Jul 2024 21:51:45 -0500 Subject: [PATCH 074/211] fix: add null menu handler, for use with API apps --- tailbone/menus.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tailbone/menus.py b/tailbone/menus.py index 84c12343..abd0b58b 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -745,3 +745,18 @@ class MenuHandler(TailboneMenuHandler): "please use tailbone.menus.TailboneMenuHandler instead", DeprecationWarning, stacklevel=2) super().__init__(*args, **kwargs) + + +class NullMenuHandler(WuttaMenuHandler): + """ + Null menu handler which uses an empty menu set. + + .. note: + + This class shouldn't even exist, but for the moment, it is + useful to configure non-traditional (e.g. API) web apps to use + this, in order to avoid most of the overhead. + """ + + def make_menus(self, request, **kwargs): + return [] From af0f84762c5dfaecc0c29cf7431d84aa7231f666 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 15 Jul 2024 21:52:05 -0500 Subject: [PATCH 075/211] =?UTF-8?q?bump:=20version=200.14.1=20=E2=86=92=20?= =?UTF-8?q?0.14.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df38a20f..c27cc130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.14.2 (2024-07-15) + +### Fix + +- add null menu handler, for use with API apps + ## v0.14.1 (2024-07-14) ### Fix diff --git a/pyproject.toml b/pyproject.toml index d7fa1c95..c19bb3e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.1" +version = "0.14.2" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 3aafe578f03893e0f03fd8e6ff5d57408a0daa38 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Jul 2024 18:59:35 -0500 Subject: [PATCH 076/211] fix: allow auto-collapse of header when viewing trainwreck txn --- tailbone/templates/form.mako | 60 +++++++++++++++++-- .../trainwreck/transactions/configure.mako | 13 ++++ tailbone/views/trainwreck/base.py | 12 ++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 0352b04c..9ce7039a 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -16,10 +16,53 @@ </%def> <%def name="page_content()"> - <div class="form-wrapper"> - <br /> - ${self.render_form()} - </div> + % if main_form_collapsible: + <${b}-collapse class="panel" + % if request.use_oruga: + v-model:open="mainFormPanelOpen" + % else: + :open.sync="mainFormPanelOpen" + % endif + > + <template #trigger="props"> + <div class="panel-heading" + role="button" + style="cursor: pointer;"> + + ## TODO: for some reason buefy will "reuse" the icon + ## element in such a way that its display does not + ## refresh. so to work around that, we use different + ## structure for the two icons, so buefy is forced to + ## re-draw + + <b-icon v-if="props.open" + pack="fas" + icon="caret-down"> + </b-icon> + + <span v-if="!props.open"> + <b-icon pack="fas" + icon="caret-right"> + </b-icon> + </span> + + + <strong>Transaction Header</strong> + </div> + </template> + <div class="panel-block"> + <div class="form-wrapper"> + <br /> + ${self.render_form()} + </div> + </div> + </${b}-collapse> + % else: + <div class="form-wrapper"> + <br /> + ${self.render_form()} + </div> + % endif </%def> <%def name="render_this_page()"> @@ -54,6 +97,15 @@ ${parent.render_this_page_template()} </%def> +<%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'} + </script> + % endif +</%def> + <%def name="finalize_this_page_vars()"> ${parent.finalize_this_page_vars()} % if form is not Undefined: diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako index 99b43fde..4569759b 100644 --- a/tailbone/templates/trainwreck/transactions/configure.mako +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -3,6 +3,19 @@ <%def name="form_content()"> + <h3 class="block is-size-3">Display</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field> + <b-checkbox name="tailbone.trainwreck.view_txn.autocollapse_header" + v-model="simpleSettings['tailbone.trainwreck.view_txn.autocollapse_header']" + native-value="true" + @input="settingsNeedSaved = true"> + Auto-collapse header when viewing transaction + </b-checkbox> + </b-field> + </div> + <h3 class="block is-size-3">Rotation</h3> <div class="block" style="padding-left: 2rem;"> diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index 9a6086d7..f529eb66 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -256,6 +256,7 @@ class TransactionView(MasterView): def template_kwargs_view(self, **kwargs): kwargs = super().template_kwargs_view(**kwargs) + config = self.rattail_config form = kwargs['form'] if 'custorder_xref_markers' in form: @@ -268,6 +269,12 @@ class TransactionView(MasterView): }) kwargs['custorder_xref_markers_data'] = markers + # collapse header + kwargs['main_form_collapsible'] = True + kwargs['main_form_autocollapse'] = config.get_bool( + 'tailbone.trainwreck.view_txn.autocollapse_header', + default=False) + return kwargs def get_xref_buttons(self, txn): @@ -419,6 +426,11 @@ class TransactionView(MasterView): def configure_get_simple_settings(self): return [ + # display + {'section': 'tailbone', + 'option': 'trainwreck.view_txn.autocollapse_header', + 'type': bool}, + # rotation {'section': 'trainwreck', 'option': 'use_rotation', From e88b8fc9bc25ff8b7632756f193b00ead8246ae4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 16 Jul 2024 21:21:43 -0500 Subject: [PATCH 077/211] fix: fix auto-collapse title for viewing trainwreck txn --- tailbone/templates/form.mako | 2 +- tailbone/views/trainwreck/base.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 9ce7039a..c9c8ea88 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -47,7 +47,7 @@ </span> - <strong>Transaction Header</strong> + <strong>${main_form_title}</strong> </div> </template> <div class="panel-block"> diff --git a/tailbone/views/trainwreck/base.py b/tailbone/views/trainwreck/base.py index f529eb66..9c150c6a 100644 --- a/tailbone/views/trainwreck/base.py +++ b/tailbone/views/trainwreck/base.py @@ -270,6 +270,7 @@ class TransactionView(MasterView): kwargs['custorder_xref_markers_data'] = markers # collapse header + kwargs['main_form_title'] = "Transaction Header" kwargs['main_form_collapsible'] = True kwargs['main_form_autocollapse'] = config.get_bool( 'tailbone.trainwreck.view_txn.autocollapse_header', From 9c466796dae12c11e50cc6be04c5a467e478d255 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 17 Jul 2024 18:24:21 -0500 Subject: [PATCH 078/211] =?UTF-8?q?bump:=20version=200.14.2=20=E2=86=92=20?= =?UTF-8?q?0.14.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c27cc130..70d9b6ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.14.3 (2024-07-17) + +### Fix + +- fix auto-collapse title for viewing trainwreck txn +- allow auto-collapse of header when viewing trainwreck txn + ## v0.14.2 (2024-07-15) ### Fix diff --git a/pyproject.toml b/pyproject.toml index c19bb3e2..e785fb0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.2" +version = "0.14.3" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From f4f79f170a5fefec8cbced39fab3f0eb6dff2873 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 17 Jul 2024 19:45:47 -0500 Subject: [PATCH 079/211] fix: fix modals for luigi tasks page, per oruga --- tailbone/templates/luigi/index.mako | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index bb8d1465..b5134c25 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -79,8 +79,13 @@ @click="overnightTaskLaunchInit(props.row)"> Launch </b-button> - <b-modal has-modal-card - :active.sync="overnightTaskShowLaunchDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="overnightTaskShowLaunchDialog" + % else: + :active.sync="overnightTaskShowLaunchDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -127,7 +132,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> </${b}-table-column> <template #empty> <p class="block">No tasks defined.</p> @@ -182,8 +187,13 @@ </template> </${b}-table> - <b-modal has-modal-card - :active.sync="backfillTaskShowLaunchDialog"> + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="backfillTaskShowLaunchDialog" + % else: + :active.sync="backfillTaskShowLaunchDialog" + % endif + > <div class="modal-card"> <header class="modal-card-head"> @@ -238,7 +248,7 @@ </b-button> </footer> </div> - </b-modal> + </${b}-modal> % endif From 1bba6d994744585244f91c008fd93ec4ca2a9bc9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 18 Jul 2024 17:58:59 -0500 Subject: [PATCH 080/211] fix: fix more settings persistence bug(s) for datasync/configure esp. for the profile consumers info --- tailbone/templates/datasync/configure.mako | 27 +++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 0889b144..7922d189 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -734,16 +734,9 @@ this.editingProfilePendingConsumers = [] for (let consumer of row.consumers_data) { - let pending = { + const pending = { + ...consumer, original_key: consumer.key, - key: consumer.key, - consumer_spec: consumer.consumer_spec, - consumer_dbkey: consumer.consumer_dbkey, - consumer_delay: consumer.consumer_delay, - consumer_retry_attempts: consumer.consumer_retry_attempts, - consumer_retry_delay: consumer.consumer_retry_delay, - consumer_runas: consumer.consumer_runas, - enabled: consumer.enabled, } this.editingProfilePendingConsumers.push(pending) } @@ -791,8 +784,8 @@ this.editingProfilePendingWatcherKwargs.splice(i, 1) } - ThisPage.methods.findOriginalConsumer = function(key) { - for (let consumer of this.editingProfile.consumers_data) { + ThisPage.methods.findConsumer = function(profileConsumers, key) { + for (const consumer of profileConsumers) { if (consumer.key == key) { return consumer } @@ -803,9 +796,12 @@ const row = this.editingProfile const newRow = !row.key + let originalProfile = null if (newRow) { row.consumers_data = [] this.profilesData.push(row) + } else { + originalProfile = this.findProfile(row) } row.key = this.editingProfileKey @@ -853,7 +849,8 @@ for (let pending of this.editingProfilePendingConsumers) { persistentConsumers.push(pending.key) if (pending.original_key) { - let consumer = this.findOriginalConsumer(pending.original_key) + const consumer = this.findConsumer(originalProfile.consumers_data, + pending.original_key) consumer.key = pending.key consumer.consumer_spec = pending.consumer_spec consumer.consumer_dbkey = pending.consumer_dbkey @@ -941,8 +938,10 @@ } ThisPage.methods.updateConsumer = function() { - let pending = this.editingConsumer - let isNew = !pending.key + const pending = this.findConsumer( + this.editingProfilePendingConsumers, + this.editingConsumer.key) + const isNew = !pending.key pending.key = this.editingConsumerKey pending.consumer_spec = this.editingConsumerSpec From a9495b6a7059deb256059615eb2aabd3e2308790 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 18 Jul 2024 17:59:55 -0500 Subject: [PATCH 081/211] =?UTF-8?q?bump:=20version=200.14.3=20=E2=86=92=20?= =?UTF-8?q?0.14.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d9b6ec..44157ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.14.4 (2024-07-18) + +### Fix + +- fix more settings persistence bug(s) for datasync/configure +- fix modals for luigi tasks page, per oruga + ## v0.14.3 (2024-07-17) ### Fix diff --git a/pyproject.toml b/pyproject.toml index e785fb0c..5cc0470b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.3" +version = "0.14.4" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 08a89c490a5ffa07599ec3bee928d07170ca4d78 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 21 Jul 2024 20:20:43 -0500 Subject: [PATCH 082/211] fix: avoid duplicate `partial` param when grid reloads data --- tailbone/templates/grids/complete.mako | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index e200cdc3..a0f927d3 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -480,7 +480,9 @@ } else { params = new URLSearchParams(params) } - params.append('partial', true) + if (!params.has('partial')) { + params.append('partial', true) + } params = params.toString() this.loading = true From 458c95696a1faab6ea1f567ca38c6b00046f98f4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 3 Aug 2024 14:13:16 -0500 Subject: [PATCH 083/211] fix: use auth handler instead of deprecated auth functions --- tailbone/views/users.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index dd3f7f7b..b641e578 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -28,8 +28,6 @@ import sqlalchemy as sa from sqlalchemy import orm from rattail.db.model import User, UserEvent -from rattail.db.auth import (administrator_role, guest_role, - authenticated_role, set_user_password) import colander from deform import widget as dfwidget @@ -360,17 +358,19 @@ class UserView(PrincipalMasterView): return tokens def get_possible_roles(self): - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model # some roles should never have users "belong" to them excluded = [ - guest_role(self.Session()).uuid, - authenticated_role(self.Session()).uuid, + auth.get_role_anonymous(self.Session()).uuid, + auth.get_role_authenticated(self.Session()).uuid, ] # only allow "root" user to change true admin role membership if not self.request.is_root: - excluded.append(administrator_role(self.Session()).uuid) + excluded.append(auth.get_role_administrator(self.Session()).uuid) # basic list, minus exclusions so far roles = self.Session.query(model.Role)\ @@ -385,7 +385,9 @@ class UserView(PrincipalMasterView): return roles.order_by(model.Role.name) def objectify(self, form, data=None): - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model # create/update user as per normal if data is None: @@ -420,7 +422,7 @@ class UserView(PrincipalMasterView): # maybe set user password if 'set_password' in form and data['set_password']: - set_user_password(user, data['set_password']) + auth.set_user_password(user, data['set_password']) # update roles for user self.update_roles(user, data) @@ -433,10 +435,12 @@ class UserView(PrincipalMasterView): if 'roles' not in data: return - model = self.model + app = self.get_rattail_app() + auth = app.get_auth_handler() + model = app.model old_roles = set([r.uuid for r in user.roles]) new_roles = data['roles'] - admin = administrator_role(self.Session()) + admin = auth.get_role_administrator(self.Session()) # add any new roles for the user, taking care not to add the admin role # unless acting as root From 5ec899cf084b67806ae6e21578c6c04071fa5f22 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 3 Aug 2024 17:43:46 -0500 Subject: [PATCH 084/211] =?UTF-8?q?bump:=20version=200.14.4=20=E2=86=92=20?= =?UTF-8?q?0.14.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44157ba6..412e6e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.14.5 (2024-08-03) + +### Fix + +- use auth handler instead of deprecated auth functions +- avoid duplicate `partial` param when grid reloads data + ## v0.14.4 (2024-07-18) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 5cc0470b..0783f2bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.4" +version = "0.14.5" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 3b92bb3a9e365a761b48335d42cca4d6f86e01b8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 4 Aug 2024 14:56:12 -0500 Subject: [PATCH 085/211] fix: use wuttaweb logic for `util.get_form_data()` --- docs/api/util.rst | 6 ++++ docs/index.rst | 1 + tailbone/forms/core.py | 10 +++++-- tailbone/util.py | 18 ++++++------ tailbone/views/purchasing/receiving.py | 8 +++--- tests/test_util.py | 39 ++++++++++++++++++++++++++ 6 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 docs/api/util.rst create mode 100644 tests/test_util.py diff --git a/docs/api/util.rst b/docs/api/util.rst new file mode 100644 index 00000000..35e66ed3 --- /dev/null +++ b/docs/api/util.rst @@ -0,0 +1,6 @@ + +``tailbone.util`` +================= + +.. automodule:: tailbone.util + :members: diff --git a/docs/index.rst b/docs/index.rst index 3ca6d4e2..d964086f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,6 +52,7 @@ Package API: api/grids.core api/progress api/subscribers + api/util api/views/batch api/views/batch.vendorcatalog api/views/core diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 11d489a7..60c2f61b 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 prettify, pretty_boolean +from rattail.util import pretty_boolean from rattail.db.util import get_fieldnames import colander @@ -47,8 +47,10 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML +from wuttaweb.util import get_form_data + from tailbone.db import Session -from tailbone.util import raw_datetime, get_form_data, render_markdown +from tailbone.util import raw_datetime, render_markdown from tailbone.forms import types from tailbone.forms.widgets import (ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget, @@ -570,7 +572,9 @@ class Form(object): self.schema[key].title = label def get_label(self, key): - return self.labels.get(key, prettify(key)) + config = self.request.rattail_config + app = config.get_app() + return self.labels.get(key, app.make_title(key)) def set_readonly(self, key, readonly=True): if readonly: diff --git a/tailbone/util.py b/tailbone/util.py index c1a0e1d5..9a0314a0 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -39,6 +39,8 @@ 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 + log = logging.getLogger(__name__) @@ -75,17 +77,13 @@ def csrf_token(request, name='_csrf'): def get_form_data(request): """ - 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. + DEPECATED - use :func:`wuttaweb:wuttaweb.util.get_form_data()` + instead. """ - # 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 + 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) def get_global_search_options(request): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index be15c1a8..55936184 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 prettify, simple_error +from rattail.util import 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, grids -from tailbone.util import get_form_data from tailbone.views.purchasing import PurchasingBatchView diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..46684f0c --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,39 @@ +# -*- 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'}) From 9d2684046ff4e5bf4b0c0da979e2cb604a915638 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 15:00:11 -0500 Subject: [PATCH 086/211] feat: move more subscriber logic to wuttaweb --- tailbone/subscribers.py | 66 ++++++++++------------------------------- 1 file changed, 15 insertions(+), 51 deletions(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 181c84bc..c783287b 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -52,30 +52,17 @@ def new_request(event): """ Event hook called when processing a new request. - This first invokes the upstream hook: - :func:`wuttaweb:wuttaweb.subscribers.new_request()` + This first invokes the upstream hooks: + + * :func:`wuttaweb:wuttaweb.subscribers.new_request()` + * :func:`wuttaweb:wuttaweb.subscribers.new_request_set_user()` 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 ``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``. + will be the same as :attr:`wuttaweb:request.wutta_config`. .. method:: request.has_perm(name) @@ -94,10 +81,9 @@ def new_request(event): then in the base template all registered components will be properly loaded. """ - # log.debug("new request: %s", event) request = event.request - # invoke upstream logic + # invoke main upstream logic # nb. this sets request.wutta_config base.new_request(event) @@ -109,25 +95,20 @@ def new_request(event): rattail_config = config request.rattail_config = rattail_config - 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 + 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 - request.set_property(user, reify=True) + # invoke upstream hook to set user + base.new_request_set_user(event, user_getter=user_getter, db_session=Session()) # assign client IP address to the session, for sake of versioning Session().continuum_remote_addr = request.client_addr - request.is_admin = auth.user_is_admin(request.user) - request.is_root = request.is_admin and request.session.get('is_root', False) - # TODO: why would this ever be null? if rattail_config: @@ -286,27 +267,10 @@ 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 From 2903b376b5038a495feaef5a70c3a31a75466476 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 15:35:06 -0500 Subject: [PATCH 087/211] =?UTF-8?q?bump:=20version=200.14.5=20=E2=86=92=20?= =?UTF-8?q?0.15.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 412e6e4a..6f1e1ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.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/pyproject.toml b/pyproject.toml index 0783f2bc..1d05052d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.14.5" +version = "0.15.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 91ea9021d7aaceb17a7cd56cd48ece52a71abb31 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 21:50:22 -0500 Subject: [PATCH 088/211] fix: move magic `b` template context var to wuttaweb --- tailbone/subscribers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index c783287b..02c4e518 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -186,7 +186,6 @@ 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', From bd1993f44029d4c0546a5d5224ef06680ce74ca6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 5 Aug 2024 22:57:02 -0500 Subject: [PATCH 089/211] =?UTF-8?q?bump:=20version=200.15.0=20=E2=86=92=20?= =?UTF-8?q?0.15.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1e1ac3..6a02e734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.15.1 (2024-08-05) + +### Fix + +- move magic `b` template context var to wuttaweb + ## v0.15.0 (2024-08-05) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 1d05052d..9e68e401 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.0" +version = "0.15.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 518c108c883a3bcceb431c10394d3176922f4658 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Aug 2024 10:36:20 -0500 Subject: [PATCH 090/211] fix: use auth handler, avoid legacy calls for role/perm checks --- tailbone/views/principal.py | 2 +- tailbone/views/roles.py | 57 +++++++++++++++++++++++-------------- tailbone/views/users.py | 2 +- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index fb09306b..b053453d 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -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_guest=self.include_guest, + include_anonymous=self.include_guest, include_authenticated=self.include_authenticated) if checked: label = perms[key]['label'] diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 0316ea87..09633c6e 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -30,7 +30,6 @@ 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 @@ -107,8 +106,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() + # only "root" can edit Administrator - if role is administrator_role(self.Session()): + if role is auth.get_role_administrator(self.Session()): return self.request.is_root # only "admin" can edit "admin-ish" roles @@ -116,11 +118,11 @@ class RoleView(PrincipalMasterView): return self.request.is_admin # can edit Authenticated only if user has permission - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return self.has_perm('edit_authenticated') # can edit Guest only if user has permission - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return self.has_perm('edit_guest') # current user can edit their own roles, only if they have permission @@ -139,11 +141,14 @@ class RoleView(PrincipalMasterView): if role.node_type and role.node_type != self.rattail_config.node_type(): return False - if role is administrator_role(self.Session()): + app = self.get_rattail_app() + auth = app.get_auth_handler() + + if role is auth.get_role_administrator(self.Session()): return False - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return False - if role is guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return False # only "admin" can delete "admin-ish" roles @@ -186,17 +191,17 @@ class RoleView(PrincipalMasterView): # session_timeout f.set_renderer('session_timeout', self.render_session_timeout) - if self.editing and role is guest_role(self.Session()): + if self.editing and role is auth.get_role_anonymous(self.Session()): f.set_readonly('session_timeout') # sync_me, node_type if not self.creating: include = True - if role is administrator_role(self.Session()): + if role is auth.get_role_administrator(self.Session()): include = False - elif role is authenticated_role(self.Session()): + elif role is auth.get_role_authenticated(self.Session()): include = False - elif role is guest_role(self.Session()): + elif role is auth.get_role_anonymous(self.Session()): include = False if not include: f.remove('sync_me', 'sync_users', 'node_type') @@ -227,7 +232,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_guest=False, + include_anonymous=False, include_authenticated=False): granted.append(key) f.set_default('permissions', granted) @@ -235,12 +240,14 @@ 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 guest_role(self.Session()): + if role is auth.get_role_anonymous(self.Session()): return ("The guest role is implied for all anonymous users, " "i.e. when not logged in.") - if role is authenticated_role(self.Session()): + if role is auth.get_role_authenticated(self.Session()): return ("The authenticated role is implied for all users, " "but only when logged in.") @@ -308,7 +315,9 @@ class RoleView(PrincipalMasterView): return available def render_session_timeout(self, role, field): - if role is guest_role(self.Session()): + app = self.get_rattail_app() + auth = app.get_auth_handler() + if role is auth.get_role_anonymous(self.Session()): return "(not applicable)" if role.session_timeout is None: return "" @@ -347,6 +356,8 @@ 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: @@ -362,8 +373,8 @@ class RoleView(PrincipalMasterView): else: kwargs['users'] = None - kwargs['guest_role'] = guest_role(self.Session()) - kwargs['authenticated_role'] = authenticated_role(self.Session()) + kwargs['guest_role'] = auth.get_role_anonymous(self.Session()) + kwargs['authenticated_role'] = auth.get_role_authenticated(self.Session()) role = kwargs['instance'] if role not in (kwargs['guest_role'], kwargs['authenticated_role']): @@ -384,9 +395,11 @@ class RoleView(PrincipalMasterView): return kwargs def before_delete(self, role): - admin = administrator_role(self.Session()) - guest = guest_role(self.Session()) - authenticated = authenticated_role(self.Session()) + 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()) 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'))) @@ -402,7 +415,7 @@ class RoleView(PrincipalMasterView): .options(orm.joinedload(model.Role._permissions)) roles = [] for role in all_roles: - if auth.has_permission(session, role, permission, include_guest=False): + if auth.has_permission(session, role, permission, include_anonymous=False): roles.append(role) return roles @@ -475,7 +488,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_guest=False): + include_anonymous=False): sheet.cell(row=writing_row, column=col, value="X") writing_row += 1 diff --git a/tailbone/views/users.py b/tailbone/views/users.py index b641e578..1012575a 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -279,7 +279,7 @@ class UserView(PrincipalMasterView): permissions = self.request.registry.settings.get('tailbone_permissions', {}) f.set_renderer('permissions', PermissionsRenderer(request=self.request, permissions=permissions, - include_guest=True, + include_anonymous=True, include_authenticated=True)) else: f.remove('permissions') From 80dc4eb7a9a619ba1fa39372045f63f7894aeff1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 6 Aug 2024 23:19:14 -0500 Subject: [PATCH 091/211] =?UTF-8?q?bump:=20version=200.15.1=20=E2=86=92=20?= =?UTF-8?q?0.15.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a02e734..733d990b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.15.2 (2024-08-06) + +### Fix + +- use auth handler, avoid legacy calls for role/perm checks + ## v0.15.1 (2024-08-05) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 9e68e401..54f4df73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.1" +version = "0.15.2" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From ffd694e7b72ae11faf09a086d7f36681f12094e7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 8 Aug 2024 19:39:01 -0500 Subject: [PATCH 092/211] fix: fix timepicker `parseTime()` when value is null --- tailbone/templates/themes/butterball/field-components.mako | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tailbone/templates/themes/butterball/field-components.mako b/tailbone/templates/themes/butterball/field-components.mako index d79c88f4..917083c4 100644 --- a/tailbone/templates/themes/butterball/field-components.mako +++ b/tailbone/templates/themes/butterball/field-components.mako @@ -517,6 +517,9 @@ }, parseTime(value) { + if (!value) { + return value + } if (value.getHours) { return value From 0b8315fc7876ca0cc43547bb0df21e80559a33cb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 8 Aug 2024 19:39:36 -0500 Subject: [PATCH 093/211] =?UTF-8?q?bump:=20version=200.15.2=20=E2=86=92=20?= =?UTF-8?q?0.15.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 733d990b..7cce885b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.15.3 (2024-08-08) + +### Fix + +- fix timepicker `parseTime()` when value is null + ## v0.15.2 (2024-08-06) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 54f4df73..800e8ab0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.2" +version = "0.15.3" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 7e683dfc4af7a5e9830a4fb6d70e153b917b0519 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 9 Aug 2024 10:11:38 -0500 Subject: [PATCH 094/211] fix: avoid bug when checking current theme this check is happening not only for classic views but API as well, which doesn't really have a theme.. probably need a proper fix in wuttaweb but this should be okay for now --- tailbone/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/util.py b/tailbone/util.py index 9a0314a0..eb6fb8a8 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -459,8 +459,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['tailbone.theme'] - if 'butterball' in theme: + theme = request.registry.settings.get('tailbone.theme') + if theme and 'butterball' in theme: return True return False From b5f0ecb165fd9d480577b561ca0cff49ba0dea96 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 9 Aug 2024 10:13:00 -0500 Subject: [PATCH 095/211] =?UTF-8?q?bump:=20version=200.15.3=20=E2=86=92=20?= =?UTF-8?q?0.15.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cce885b..05648c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.15.4 (2024-08-09) + +### Fix + +- avoid bug when checking current theme + ## v0.15.3 (2024-08-08) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 800e8ab0..4478aef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.3" +version = "0.15.4" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From f2fce2e30526db7c85c69b0dfc6162c4d2f7e6b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 9 Aug 2024 19:22:26 -0500 Subject: [PATCH 096/211] fix: assign convenience attrs for all views (config, app, enum, model) --- tailbone/views/core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tailbone/views/core.py b/tailbone/views/core.py index b0658d80..88b2519f 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -58,9 +58,10 @@ class View: config = self.rattail_config if config: - app = config.get_app() - self.model = app.model - self.enum = config.get_enum() + self.config = config + self.app = self.config.get_app() + self.model = self.app.model + self.enum = self.app.enum @property def rattail_config(self): From d57efba3811bc286fe49290f66a69df04b814633 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 9 Aug 2024 19:48:51 -0500 Subject: [PATCH 097/211] =?UTF-8?q?bump:=20version=200.15.4=20=E2=86=92=20?= =?UTF-8?q?0.15.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05648c25..de92a834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.15.5 (2024-08-09) + +### Fix + +- assign convenience attrs for all views (config, app, enum, model) + ## v0.15.4 (2024-08-09) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 4478aef5..c4335903 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.4" +version = "0.15.5" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 2c46fde74288da664561277f9637a571a494dcaf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Aug 2024 08:43:54 -0500 Subject: [PATCH 098/211] fix: simplify verbiage for batch execution panel --- tailbone/templates/batch/view.mako | 2 -- 1 file changed, 2 deletions(-) diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 5e3328d9..63cb9056 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -85,13 +85,11 @@ <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 From 1f752530d2d757028aa52ac6518cb3d7b0462aed Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 10 Aug 2024 13:49:41 -0500 Subject: [PATCH 099/211] fix: avoid `before_render` subscriber hook for web API the purpose of that function is to setup extra template context, but API views always render as 'json' with no template --- tailbone/webapi.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tailbone/webapi.py b/tailbone/webapi.py index 1c2fa106..7c0e9b41 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -91,15 +91,21 @@ def make_pyramid_config(settings): return pyramid_config -def main(global_config, **settings): +def main(global_config, views='tailbone.api', **settings): """ This function returns a Pyramid WSGI application. """ rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) - # bring in some Tailbone - pyramid_config.include('tailbone.subscribers') - pyramid_config.include('tailbone.api') + # 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) return pyramid_config.make_wsgi_app() From b53479f8e46db1e622a773c8367f719d289185f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 13 Aug 2024 11:21:38 -0500 Subject: [PATCH 100/211] =?UTF-8?q?bump:=20version=200.15.5=20=E2=86=92=20?= =?UTF-8?q?0.15.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de92a834..3836ff08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index c4335903..e515a0d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.5" +version = "0.15.6" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.17.0", + "rattail[db,bouncer]>=0.17.11", "sa-filters", "simplejson", "transaction", From a6ce5eb21d7ba61f187ac1093abc08b4d9ccdb01 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 14:34:20 -0500 Subject: [PATCH 101/211] feat: refactor forms/grids/views/templates per wuttaweb compat this starts to get things more aligned between wuttaweb and tailbone. the use case in mind so far is for a wuttaweb view to be included in a tailbone app. form and grid classes now have some new methods to match wuttaweb, so templates call the shared method names where possible. templates can no longer assume they have tailbone-native master view, form, grid etc. so must inspect context more closely in some cases. --- tailbone/app.py | 13 +- tailbone/auth.py | 29 +--- tailbone/config.py | 5 +- tailbone/forms/core.py | 112 +++++++++++-- tailbone/grids/core.py | 88 +++++++++- tailbone/subscribers.py | 71 +++----- tailbone/templates/base.mako | 8 +- tailbone/templates/form.mako | 8 +- tailbone/templates/forms/deform.mako | 41 +++-- tailbone/templates/forms/vue_template.mako | 3 + tailbone/templates/grids/complete.mako | 94 +++++------ tailbone/templates/grids/vue_template.mako | 3 + tailbone/templates/master/create.mako | 2 +- tailbone/templates/master/delete.mako | 10 +- tailbone/templates/master/form.mako | 6 +- tailbone/templates/master/index.mako | 128 +++++++-------- tailbone/templates/master/view.mako | 10 +- tailbone/templates/people/index.mako | 4 +- tailbone/templates/people/view.mako | 4 +- .../templates/principal/find_by_perm.mako | 4 +- .../templates/themes/butterball/base.mako | 28 ++-- tailbone/views/master.py | 4 +- tailbone/views/principal.py | 2 +- tailbone/views/roles.py | 4 +- tailbone/views/users.py | 2 +- tests/__init__.py | 3 - tests/forms/__init__.py | 0 tests/forms/test_core.py | 153 ++++++++++++++++++ tests/grids/__init__.py | 0 tests/grids/test_core.py | 139 ++++++++++++++++ tests/test_app.py | 43 +++-- tests/test_auth.py | 3 + tests/test_config.py | 12 ++ tests/test_subscribers.py | 58 +++++++ tests/util.py | 75 +++++++++ tests/views/test_master.py | 26 +++ tests/views/test_principal.py | 29 ++++ tests/views/test_roles.py | 80 +++++++++ tests/views/test_users.py | 33 ++++ 39 files changed, 1037 insertions(+), 300 deletions(-) create mode 100644 tailbone/templates/forms/vue_template.mako create mode 100644 tailbone/templates/grids/vue_template.mako create mode 100644 tests/forms/__init__.py create mode 100644 tests/forms/test_core.py create mode 100644 tests/grids/__init__.py create mode 100644 tests/grids/test_core.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_config.py create mode 100644 tests/test_subscribers.py create mode 100644 tests/util.py create mode 100644 tests/views/test_master.py create mode 100644 tests/views/test_principal.py create mode 100644 tests/views/test_roles.py create mode 100644 tests/views/test_users.py diff --git a/tailbone/app.py b/tailbone/app.py index b7220703..5e8e49d9 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -189,9 +189,16 @@ def make_pyramid_config(settings, configure_csrf=True): for spec in includes: config.include(spec) - # Add some permissions magic. - config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + # add some permissions magic + config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + # TODO: deprecate / remove these + config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') # and some similar magic for certain master views config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') diff --git a/tailbone/auth.py b/tailbone/auth.py index 826c5d40..fbe6bf2f 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -27,7 +27,7 @@ Authentication & Authorization import logging import re -from rattail.util import prettify, NOTSET +from rattail.util import NOTSET from zope.interface import implementer from pyramid.authentication import SessionAuthenticationHelper @@ -159,30 +159,3 @@ class TailboneSecurityPolicy: 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 ce1691ae..8392ba0a 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -26,13 +26,14 @@ Rattail config extension for Tailbone import warnings -from rattail.config import ConfigExtension as BaseExtension +from wuttjamaican.conf import WuttaConfigExtension + from rattail.db.config import configure_session from tailbone.db import Session -class ConfigExtension(BaseExtension): +class ConfigExtension(WuttaConfigExtension): """ Rattail config extension for Tailbone. Does the following: diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 60c2f61b..eeae4537 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -47,7 +47,7 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from wuttaweb.util import get_form_data +from wuttaweb.util import get_form_data, make_json_safe from tailbone.db import Session from tailbone.util import raw_datetime, render_markdown @@ -328,7 +328,7 @@ class Form(object): """ Base class for all forms. """ - save_label = "Save" + save_label = "Submit" update_label = "Save" show_cancel = True auto_disable = True @@ -339,10 +339,12 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, component='tailbone-form', + action_url=None, cancel_url=None, + vue_tagname=None, vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, + **kwargs ): self.fields = None if fields is not None: @@ -380,7 +382,17 @@ class Form(object): self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url - self.component = component + + # vue_tagname + self.vue_tagname = vue_tagname + if not self.vue_tagname and kwargs.get('component'): + warnings.warn("component kwarg is deprecated for Form(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + self.vue_tagname = kwargs['component'] + if not self.vue_tagname: + self.vue_tagname = 'tailbone-form' + self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} self.json_data = json_data or {} @@ -393,10 +405,54 @@ class Form(object): return iter(self.fields) @property - def component_studly(self): - words = self.component.split('-') + def vue_component(self): + """ + String name for the Vue component, e.g. ``'TailboneGrid'``. + + This is a generated value based on :attr:`vue_tagname`. + """ + words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words]) + @property + def component(self): + """ + DEPRECATED - use :attr:`vue_tagname` instead. + """ + warnings.warn("Form.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + + @property + def component_studly(self): + """ + DEPRECATED - use :attr:`vue_component` instead. + """ + warnings.warn("Form.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component + + def get_button_label_submit(self): + """ """ + if hasattr(self, '_button_label_submit'): + return self._button_label_submit + + label = getattr(self, 'submit_label', None) + if label: + return label + + return self.save_label + + def set_button_label_submit(self, value): + """ """ + self._button_label_submit = value + + # wutta compat + button_label_submit = property(get_button_label_submit, + set_button_label_submit) + def __contains__(self, item): return item in self.fields @@ -805,6 +861,10 @@ 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'): @@ -843,6 +903,10 @@ class Form(object): return self.deform_form + def render_vue_template(self, template='/forms/deform.mako', **context): + """ """ + return self.render_deform(template=template, **context) + def render_deform(self, dform=None, template=None, **kwargs): if not template: template = '/forms/deform.mako' @@ -865,8 +929,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.component_studly) - context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) + context['form_kwargs'].setdefault('ref', self.vue_component) + context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component) if self.focus_spec: context['form_kwargs']['data-focus'] = self.focus_spec context['request'] = self.request @@ -878,12 +942,13 @@ class Form(object): return dict([(field, self.get_label(field)) for field in self]) - def get_field_markdowns(self): + def get_field_markdowns(self, session=None): app = self.request.rattail_config.get_app() model = app.model + session = session or Session() if not hasattr(self, 'field_markdowns'): - infos = Session.query(model.TailboneFieldInfo)\ + infos = session.query(model.TailboneFieldInfo)\ .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\ .all() self.field_markdowns = dict([(info.field_name, info.markdown_text) @@ -891,6 +956,18 @@ 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 @@ -957,6 +1034,10 @@ 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() + def render_vuejs_component(self): """ Render the Vue.js component HTML for the form. @@ -971,7 +1052,7 @@ class Form(object): kwargs = dict(self.vuejs_component_kwargs) if self.can_edit_help: kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') - return HTML.tag(self.component, **kwargs) + return HTML.tag(self.vue_tagname, **kwargs) def set_json_data(self, key, value): """ @@ -997,7 +1078,12 @@ class Form(object): templates.append(HTML.literal(render(template, context))) return HTML.literal('\n').join(templates) - def render_field_complete(self, fieldname, bfield_attrs={}): + def render_vue_field(self, fieldname, **kwargs): + """ """ + return self.render_field_complete(fieldname, **kwargs) + + def render_field_complete(self, fieldname, bfield_attrs={}, + session=None): """ Render the given field completely, i.e. with ``<b-field>`` wrapper. Note that this is meant to render *editable* fields, @@ -1015,7 +1101,7 @@ class Form(object): if self.field_visible(fieldname): label = self.get_label(fieldname) - markdowns = self.get_field_markdowns() + markdowns = self.get_field_markdowns(session=session) # these attrs will be for the <b-field> (*not* the widget) attrs = { diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index b4610a18..3f1769cf 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -198,7 +198,8 @@ class Grid: 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', + ajax_data_url=None, + vue_tagname=None, expose_direct_link=False, **kwargs): @@ -268,19 +269,63 @@ class Grid: if ajax_data_url: self.ajax_data_url = ajax_data_url elif self.request: - self.ajax_data_url = self.request.current_route_url(_query=None) + self.ajax_data_url = self.request.path_url else: self.ajax_data_url = '' - self.component = component + # vue_tagname + self.vue_tagname = vue_tagname + if not self.vue_tagname and kwargs.get('component'): + warnings.warn("component kwarg is deprecated for Grid(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + self.vue_tagname = kwargs['component'] + if not self.vue_tagname: + self.vue_tagname = 'tailbone-grid' + self.expose_direct_link = expose_direct_link self._whgrid_kwargs = kwargs @property - def component_studly(self): - words = self.component.split('-') + def vue_component(self): + """ + String name for the Vue component, e.g. ``'TailboneGrid'``. + + This is a generated value based on :attr:`vue_tagname`. + """ + words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words]) + @property + def component(self): + """ + DEPRECATED - use :attr:`vue_tagname` instead. + """ + warnings.warn("Grid.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("Grid.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component + + @property + def actions(self): + """ """ + actions = [] + if self.main_actions: + actions.extend(self.main_actions) + if self.more_actions: + actions.extend(self.more_actions) + return actions + def make_columns(self): """ Return a default list of columns, based on :attr:`model_class`. @@ -1334,6 +1379,21 @@ class Grid: data = self.pager return 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) + def render_complete(self, template='/grids/complete.mako', **kwargs): """ Render the grid, complete with filters. Note that this also @@ -1359,7 +1419,8 @@ class Grid: context['request'] = self.request context.setdefault('allow_save_defaults', True) context.setdefault('view_click_handler', self.get_view_click_handler()) - return render(template, context) + html = render(template, context) + return HTML.literal(html) def render_buefy(self, **kwargs): warnings.warn("Grid.render_buefy() is deprecated; " @@ -1575,6 +1636,10 @@ class Grid: return True return False + def get_vue_columns(self): + """ """ + return self.get_table_columns() + def get_table_columns(self): """ Return a list of dicts representing all grid columns. Meant @@ -1600,11 +1665,19 @@ class Grid: if hasattr(rowobj, 'uuid'): return rowobj.uuid + 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.make_visible_data() data = [] @@ -1704,7 +1777,8 @@ class Grid: else: results['total_items'] = count - return results + self._table_data = results + return self._table_data def set_action_urls(self, row, rowobj, i): """ diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 02c4e518..268d4818 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -48,7 +48,7 @@ from tailbone.util import get_available_themes, get_global_search_options log = logging.getLogger(__name__) -def new_request(event): +def new_request(event, session=None): """ Event hook called when processing a new request. @@ -64,15 +64,6 @@ def new_request(event): Reference to the app :term:`config object`. Note that this will be the same as :attr:`wuttaweb:request.wutta_config`. - .. 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) Function to register a Vue component for use with the app. @@ -90,6 +81,7 @@ def new_request(event): config = request.wutta_config app = config.get_app() auth = app.get_auth_handler() + session = session or Session() # compatibility rattail_config = config @@ -104,50 +96,31 @@ def new_request(event): return user # invoke upstream hook to set user - base.new_request_set_user(event, user_getter=user_getter, db_session=Session()) + base.new_request_set_user(event, user_getter=user_getter, db_session=session) # assign client IP address to the session, for sake of versioning - Session().continuum_remote_addr = request.client_addr + if hasattr(request, 'client_addr'): + session.continuum_remote_addr = request.client_addr - # TODO: why would this ever be null? - if rattail_config: + # 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() - app = rattail_config.get_app() - auth = app.get_auth_handler() - request.tailbone_cached_permissions = auth.get_permissions( - Session(), request.user) + if tagname in request._tailbone_registered_components: + log.warning("component with tagname '%s' already registered " + "with class '%s' but we are replacing that with " + "class '%s'", + tagname, + request._tailbone_registered_components[tagname], + classname) - 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 + request._tailbone_registered_components[tagname] = classname + request.register_component = register_component def before_render(event): diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index c4cbd648..6811397b 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -153,12 +153,16 @@ <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> @@ -856,7 +860,7 @@ feedbackMessage: "", % if expose_theme_picker and request.has_perm('common.change_app_theme'): - globalTheme: ${json.dumps(theme)|n}, + globalTheme: ${json.dumps(theme or None)|n}, referrer: location.href, % endif @@ -866,7 +870,7 @@ globalSearchActive: false, globalSearchTerm: '', - globalSearchData: ${json.dumps(global_search_data)|n}, + globalSearchData: ${json.dumps(global_search_data or [])|n}, mountedHooks: [], } diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index c9c8ea88..fec721fd 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_deform(buttons=capture(self.render_form_buttons))|n} + ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n} </%def> <%def name="render_form()"> <div class="form"> - ${form.render_vuejs_component()} + ${form.render_vue_tag()} </div> </%def> @@ -111,9 +111,9 @@ % if form is not Undefined: <script type="text/javascript"> - ${form.component_studly}.data = function() { return ${form.component_studly}Data } + ${form.vue_component}.data = function() { return ${form.vue_component}Data } - Vue.component('${form.component}', ${form.component_studly}) + Vue.component('${form.vue_tagname}', ${form.vue_component}) </script> % endif diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 00cf2c50..26c8b4ee 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -1,19 +1,19 @@ ## -*- coding: utf-8; -*- -<% request.register_component(form.component, form.component_studly) %> +<% request.register_component(form.vue_tagname, form.vue_component) %> -<script type="text/x-template" id="${form.component}-template"> +<script type="text/x-template" id="${form.vue_tagname}-template"> <div> % if not form.readonly: - ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **form_kwargs)} + ${h.form(form.action_url, id=dform.formid, method='post', enctype='multipart/form-data', **(form_kwargs or {}))} ${h.csrf_token(request)} % endif <section> % if form_body is not Undefined and form_body: ${form_body|n} - % elif form.grouping: + % elif getattr(form, 'grouping', None): % for group in form.grouping: <nav class="panel"> <p class="panel-heading">${group}</p> @@ -27,8 +27,8 @@ </nav> % endfor % else: - % for field in form.fields: - ${form.render_field_complete(field)} + % for fieldname in form.fields: + ${form.render_vue_field(fieldname, session=session)} % endfor % endif </section> @@ -54,20 +54,20 @@ <input type="reset" value="Reset" class="button" /> % endif ## TODO: deprecate / remove the latter option here - % if form.auto_disable_save or form.auto_disable: + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: <b-button type="is-primary" native-type="submit" - :disabled="${form.component_studly}Submitting" + :disabled="${form.vue_component}Submitting" icon-pack="fas" icon-left="save"> - {{ ${form.component_studly}ButtonText }} + {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }} </b-button> % else: <b-button type="is-primary" native-type="submit" icon-pack="fas" icon-left="save"> - ${getattr(form, 'submit_label', getattr(form, 'save_label', "Submit"))} + ${form.button_label_submit} </b-button> % endif </div> @@ -122,8 +122,8 @@ <script type="text/javascript"> - let ${form.component_studly} = { - template: '#${form.component}-template', + let ${form.vue_component} = { + template: '#${form.vue_tagname}-template', mixins: [FormPosterMixin], components: {}, props: { @@ -136,10 +136,9 @@ methods: { ## TODO: deprecate / remove the latter option here - % 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..." + % 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 }, % endif @@ -178,7 +177,7 @@ } } - let ${form.component_studly}Data = { + let ${form.vue_component}Data = { ## TODO: should find a better way to handle CSRF token csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, @@ -198,16 +197,14 @@ % if not form.readonly: % for field in form.fields: % if field in dform: - <% field = dform[field] %> - field_model_${field.name}: ${form.get_vuejs_model_value(field)|n}, + field_model_${field}: ${json.dumps(form.get_vue_field_value(field))|n}, % endif % endfor % endif ## TODO: deprecate / remove the latter option here - % 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}, + % if getattr(form, 'auto_disable_submit', False) or form.auto_disable_save or form.auto_disable: + ${form.vue_component}Submitting: false, % endif } diff --git a/tailbone/templates/forms/vue_template.mako b/tailbone/templates/forms/vue_template.mako new file mode 100644 index 00000000..ac096f67 --- /dev/null +++ b/tailbone/templates/forms/vue_template.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/forms/deform.mako" /> +${parent.body()} diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index a0f927d3..fc48916b 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -1,15 +1,15 @@ ## -*- coding: utf-8; -*- -<% request.register_component(grid.component, grid.component_studly) %> +<% request.register_component(grid.vue_tagname, grid.vue_component) %> -<script type="text/x-template" id="${grid.component}-template"> +<script type="text/x-template" id="${grid.vue_tagname}-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 grid.filterable: + % if getattr(grid, 'filterable', False): ## TODO: stop using |n filter ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n} % endif @@ -55,7 +55,7 @@ :checkable="checkable" - % if grid.checkboxes: + % if getattr(grid, 'checkboxes', False): % if request.use_oruga: v-model:checked-rows="checkedRows" % else: @@ -66,20 +66,22 @@ % endif % endif - % if grid.check_handler: + % if getattr(grid, 'check_handler', None): @check="${grid.check_handler}" % endif - % if grid.check_all_handler: + % if getattr(grid, 'check_all_handler', None): @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 - % if grid.sortable: + % if getattr(grid, 'sortable', False): backend-sorting @sort="onSort" @sorting-priority-removed="sortingPriorityRemoved" @@ -101,7 +103,7 @@ sort-multiple-key="ctrlKey" % endif - % if grid.click_handlers: + % if getattr(grid, 'click_handlers', None): @cellclick="cellClick" % endif @@ -119,17 +121,17 @@ :hoverable="true" :narrowed="true"> - % for column in grid_columns: + % for column in grid.get_vue_columns(): <${b}-table-column field="${column['field']}" label="${column['label']}" v-slot="props" - :sortable="${json.dumps(column['sortable'])}" - % if grid.is_searchable(column['field']): + :sortable="${json.dumps(column.get('sortable', False))}" + % if hasattr(grid, 'is_searchable') and grid.is_searchable(column['field']): searchable % endif cell-class="c_${column['field']}" - :visible="${json.dumps(column['visible'])}"> - % if column['field'] in grid.raw_renderers: + :visible="${json.dumps(column.get('visible', True))}"> + % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers: ${grid.raw_renderers[column['field']]()} % elif grid.is_linked(column['field']): <a :href="props.row._action_url_view" @@ -144,20 +146,20 @@ </${b}-table-column> % endfor - % if grid.main_actions or grid.more_actions: + % if grid.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.main_actions + grid.more_actions: + % for action in grid.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 action.click_handler: + % if getattr(action, 'click_handler', None): @click.prevent="${action.click_handler}" % endif - % if action.target: + % if getattr(action, 'target', None): target="${action.target}" % endif > @@ -192,7 +194,7 @@ <template #footer> <div style="display: flex; justify-content: space-between;"> - % if grid.expose_direct_link: + % if getattr(grid, 'expose_direct_link', False): <b-button type="is-primary" size="is-small" @click="copyDirectLink()" @@ -207,7 +209,7 @@ <div></div> % endif - % if grid.pageable: + % if getattr(grid, 'pageable', False): <div v-if="firstItem" style="display: flex; gap: 0.5rem; align-items: center;"> <span> @@ -234,7 +236,7 @@ </${b}-table> ## dummy input field needed for sharing links on *insecure* sites - % if request.scheme == 'http': + % if getattr(request, 'scheme', None) == 'http': <b-input v-model="shareLink" ref="shareLink" v-show="shareLink"></b-input> % endif @@ -243,30 +245,30 @@ <script type="text/javascript"> - let ${grid.component_studly}CurrentData = ${json.dumps(grid_data['data'])|n} + let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n} - let ${grid.component_studly}Data = { + let ${grid.vue_component}Data = { loading: false, - ajaxDataUrl: ${json.dumps(grid.ajax_data_url)|n}, + ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n}, savingDefaults: false, - data: ${grid.component_studly}CurrentData, - rowStatusMap: ${json.dumps(grid_data['row_status_map'])|n}, + data: ${grid.vue_component}CurrentData, + rowStatusMap: ${json.dumps(grid_data['row_status_map'] if grid_data is not Undefined else {})|n}, - checkable: ${json.dumps(grid.checkboxes)|n}, - % if grid.checkboxes: + checkable: ${json.dumps(getattr(grid, 'checkboxes', False))|n}, + % if getattr(grid, 'checkboxes', False): checkedRows: ${grid_data['checked_rows_code']|n}, % 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}, + paginated: ${json.dumps(getattr(grid, 'pageable', False))|n}, + total: ${len(grid_data['data']) if static_data else (grid_data['total_items'] if grid_data is not Undefined else 0)}, + perPage: ${json.dumps(grid.pagesize if getattr(grid, 'pageable', False) else None)|n}, + currentPage: ${json.dumps(grid.page if getattr(grid, 'pageable', False) else None)|n}, + firstItem: ${json.dumps(grid_data['first_item'] if getattr(grid, 'pageable', False) else None)|n}, + lastItem: ${json.dumps(grid_data['last_item'] if getattr(grid, 'pageable', False) else None)|n}, - % if grid.sortable: + % if getattr(grid, 'sortable', False): ## TODO: there is a bug (?) which prevents the arrow from ## displaying for simple default single-column sort. so to @@ -289,19 +291,19 @@ % endif ## filterable: ${json.dumps(grid.filterable)|n}, - filters: ${json.dumps(filters_data if grid.filterable else None)|n}, - filtersSequence: ${json.dumps(filters_sequence if grid.filterable else None)|n}, + filters: ${json.dumps(filters_data if getattr(grid, 'filterable', False) else None)|n}, + filtersSequence: ${json.dumps(filters_sequence if getattr(grid, 'filterable', False) else None)|n}, addFilterTerm: '', addFilterShow: false, ## dummy input value needed for sharing links on *insecure* sites - % if request.scheme == 'http': + % if getattr(request, 'scheme', None) == 'http': shareLink: null, % endif } - let ${grid.component_studly} = { - template: '#${grid.component}-template', + let ${grid.vue_component} = { + template: '#${grid.vue_tagname}-template', mixins: [FormPosterMixin], @@ -358,7 +360,7 @@ directLink() { let params = new URLSearchParams(this.getAllParams()) - return `${request.current_route_url(_query=None)}?${'$'}{params}` + return `${request.path_url}?${'$'}{params}` }, }, @@ -380,7 +382,7 @@ return filtr.label || filtr.key }, - % if grid.click_handlers: + % if getattr(grid, 'click_handlers', None): cellClick(row, column, rowIndex, columnIndex) { % for key in grid.click_handlers: if (column._props.field == '${key}') { @@ -437,13 +439,13 @@ getBasicParams() { let params = {} - % if grid.sortable: + % if getattr(grid, 'sortable', False): 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: + % if getattr(grid, 'pageable', False): params.pagesize = this.perPage params.page = this.currentPage % endif @@ -488,8 +490,8 @@ this.loading = true this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { if (!data.error) { - ${grid.component_studly}CurrentData = data.data - this.data = ${grid.component_studly}CurrentData + ${grid.vue_component}CurrentData = data.data + this.data = ${grid.vue_component}CurrentData this.rowStatusMap = data.row_status_map this.total = data.total_items this.firstItem = data.first_item @@ -776,7 +778,7 @@ } else { this.checkedRows.push(row) } - % if grid.check_handler: + % if getattr(grid, 'check_handler', None): this.${grid.check_handler}(this.checkedRows, row) % endif }, diff --git a/tailbone/templates/grids/vue_template.mako b/tailbone/templates/grids/vue_template.mako new file mode 100644 index 00000000..625f046b --- /dev/null +++ b/tailbone/templates/grids/vue_template.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8; -*- +<%inherit file="/grids/complete.mako" /> +${parent.body()} diff --git a/tailbone/templates/master/create.mako b/tailbone/templates/master/create.mako index 27cd404c..d7dcbbd8 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 master.creates_multiple else model_title}</%def> +<%def name="title()">New ${model_title_plural if getattr(master, 'creates_multiple', False) else model_title}</%def> ${parent.body()} diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index 30bb50ab..c6187d55 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -27,7 +27,7 @@ <b-button type="is-primary is-danger" native-type="submit" :disabled="formSubmitting"> - {{ formButtonText }} + {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} </b-button> </div> ${h.end_form()} @@ -35,14 +35,12 @@ <%def name="modify_this_page_vars()"> ${parent.modify_this_page_vars()} - <script type="text/javascript"> + <script> - TailboneFormData.formSubmitting = false - TailboneFormData.formButtonText = "Yes, please DELETE this data forever!" + ${form.vue_component}Data.formSubmitting = false - TailboneForm.methods.submitForm = function() { + ${form.vue_component}.methods.submitForm = function() { this.formSubmitting = true - this.formButtonText = "Working, please wait..." } </script> diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index dfe56fa8..dc9743ea 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -6,13 +6,13 @@ <script type="text/javascript"> ## declare extra data needed by form - % if form is not Undefined: + % if form is not Undefined and getattr(form, 'json_data', None): % for key, value in form.json_data.items(): ${form.component_studly}Data.${key} = ${json.dumps(value)|n} % endfor % endif - % if master.deletable and instance_deletable and master.has_perm('delete') and master.delete_confirm == 'simple': + % if master.deletable and instance_deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ThisPage.methods.deleteObject = function() { if (confirm("Are you sure you wish to delete this ${model_title}?")) { @@ -23,7 +23,7 @@ % endif </script> - % if form is not Undefined: + % if form is not Undefined and hasattr(form, 'render_included_templates'): ${form.render_included_templates()} % endif diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 33592559..81c11213 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -15,7 +15,7 @@ <%def name="grid_tools()"> ## grid totals - % if master.supports_grid_totals: + % if getattr(master, 'supports_grid_totals', False): <div style="display: flex; align-items: center;"> <b-button v-if="gridTotalsDisplay == null" :disabled="gridTotalsFetching" @@ -30,7 +30,7 @@ % endif ## download search results - % if master.results_downloadable and master.has_perm('download_results'): + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): <div> <b-button type="is-primary" icon-pack="fas" @@ -180,7 +180,7 @@ % endif ## download rows for search results - % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): <b-button type="is-primary" icon-pack="fas" icon-left="download" @@ -194,7 +194,7 @@ % endif ## merge 2 objects - % if master.mergeable and request.has_perm('{}.merge'.format(permission_prefix)): + % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)): ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})} ${h.csrf_token(request)} @@ -212,7 +212,7 @@ % endif ## enable / disable selected objects - % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')} ${h.csrf_token(request)} @@ -234,7 +234,7 @@ % endif ## delete selected objects - % if master.set_deletable and master.has_perm('delete_set'): + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')} ${h.csrf_token(request)} ${h.hidden('uuids', v_model='selected_uuids')} @@ -249,7 +249,7 @@ % endif ## delete search results - % if master.bulk_deletable and request.has_perm('{}.bulk_delete'.format(permission_prefix)): + % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)): ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')} ${h.csrf_token(request)} <b-button type="is-danger" @@ -283,7 +283,7 @@ ${self.render_grid_component()} - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': + % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ${h.form('#', ref='deleteObjectForm')} ${h.csrf_token(request)} ${h.end_form()} @@ -291,17 +291,11 @@ </%def> <%def name="make_grid_component()"> - ## TODO: stop using |n filter? - ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} + ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} </%def> <%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}> + ${grid.render_vue_tag()} </%def> <%def name="make_this_page_component()"> @@ -313,10 +307,8 @@ ## finalize grid <script> - - ${grid.component_studly}.data = () => { return ${grid.component_studly}Data } - Vue.component('${grid.component}', ${grid.component_studly}) - + ${grid.vue_component}.data = function() { return ${grid.vue_component}Data } + Vue.component('${grid.vue_tagname}', ${grid.vue_component}) </script> </%def> @@ -328,11 +320,11 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - % if master.supports_grid_totals: - ${grid.component_studly}Data.gridTotalsDisplay = null - ${grid.component_studly}Data.gridTotalsFetching = false + % if getattr(master, 'supports_grid_totals', False): + ${grid.vue_component}Data.gridTotalsDisplay = null + ${grid.vue_component}Data.gridTotalsFetching = false - ${grid.component_studly}.methods.gridTotalsFetch = function() { + ${grid.vue_component}.methods.gridTotalsFetch = function() { this.gridTotalsFetching = true let url = '${url(f'{route_prefix}.fetch_grid_totals')}' @@ -344,7 +336,7 @@ }) } - ${grid.component_studly}.methods.appliedFiltersHook = function() { + ${grid.vue_component}.methods.appliedFiltersHook = function() { this.gridTotalsDisplay = null this.gridTotalsFetching = false } @@ -388,7 +380,7 @@ % endif ## delete single object - % if master.deletable and master.has_perm('delete') and master.delete_confirm == 'simple': + % if master.deletable and master.has_perm('delete') and getattr(master, 'delete_confirm', 'full') == 'simple': ThisPage.methods.deleteObject = function(url) { if (confirm("Are you sure you wish to delete this ${model_title}?")) { let form = this.$refs.deleteObjectForm @@ -399,19 +391,19 @@ % endif ## download results - % if master.results_downloadable and master.has_perm('download_results'): + % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'): - ${grid.component_studly}Data.downloadResultsFormat = '${master.download_results_default_format()}' - ${grid.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.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.downloadResultsExcludedFieldsSelected = [] - ${grid.component_studly}Data.downloadResultsIncludedFieldsSelected = [] + ${grid.vue_component}Data.downloadResultsExcludedFieldsSelected = [] + ${grid.vue_component}Data.downloadResultsIncludedFieldsSelected = [] - ${grid.component_studly}.computed.downloadResultsFieldsExcluded = function() { + ${grid.vue_component}.computed.downloadResultsFieldsExcluded = function() { let excluded = [] this.downloadResultsFieldsAvailable.forEach(field => { if (!this.downloadResultsFieldsIncluded.includes(field)) { @@ -421,7 +413,7 @@ return excluded } - ${grid.component_studly}.methods.downloadResultsExcludeFields = function() { + ${grid.vue_component}.methods.downloadResultsExcludeFields = function() { const selected = Array.from(this.downloadResultsIncludedFieldsSelected) if (!selected) { return @@ -445,7 +437,7 @@ }) } - ${grid.component_studly}.methods.downloadResultsIncludeFields = function() { + ${grid.vue_component}.methods.downloadResultsIncludeFields = function() { const selected = Array.from(this.downloadResultsExcludedFieldsSelected) if (!selected) { return @@ -466,28 +458,28 @@ }) } - ${grid.component_studly}.methods.downloadResultsUseDefaultFields = function() { + ${grid.vue_component}.methods.downloadResultsUseDefaultFields = function() { this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsDefault) this.downloadResultsFieldsMode = 'default' } - ${grid.component_studly}.methods.downloadResultsUseAllFields = function() { + ${grid.vue_component}.methods.downloadResultsUseAllFields = function() { this.downloadResultsFieldsIncluded = Array.from(this.downloadResultsFieldsAvailable) this.downloadResultsFieldsMode = 'all' } - ${grid.component_studly}.methods.downloadResultsSubmit = function() { + ${grid.vue_component}.methods.downloadResultsSubmit = function() { this.$refs.download_results_form.submit() } % endif ## download rows for results - % if master.has_rows and master.results_rows_downloadable and master.has_perm('download_results_rows'): + % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'): - ${grid.component_studly}Data.downloadResultsRowsButtonDisabled = false - ${grid.component_studly}Data.downloadResultsRowsButtonText = "Download Rows for Results" + ${grid.vue_component}Data.downloadResultsRowsButtonDisabled = false + ${grid.vue_component}Data.downloadResultsRowsButtonText = "Download Rows for Results" - ${grid.component_studly}.methods.downloadResultsRows = function() { + ${grid.vue_component}.methods.downloadResultsRows = function() { if (confirm("This will generate an Excel file which contains " + "not the results themselves, but the *rows* for " + "each.\n\nAre you sure you want this?")) { @@ -499,12 +491,12 @@ % endif ## enable / disable selected objects - % if master.supports_set_enabled_toggle and master.has_perm('enable_disable_set'): + % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'): - ${grid.component_studly}Data.enableSelectedSubmitting = false - ${grid.component_studly}Data.enableSelectedText = "Enable Selected" + ${grid.vue_component}Data.enableSelectedSubmitting = false + ${grid.vue_component}Data.enableSelectedText = "Enable Selected" - ${grid.component_studly}.computed.enableSelectedDisabled = function() { + ${grid.vue_component}.computed.enableSelectedDisabled = function() { if (this.enableSelectedSubmitting) { return true } @@ -514,7 +506,7 @@ return false } - ${grid.component_studly}.methods.enableSelectedSubmit = function() { + ${grid.vue_component}.methods.enableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -529,10 +521,10 @@ this.$refs.enable_selected_form.submit() } - ${grid.component_studly}Data.disableSelectedSubmitting = false - ${grid.component_studly}Data.disableSelectedText = "Disable Selected" + ${grid.vue_component}Data.disableSelectedSubmitting = false + ${grid.vue_component}Data.disableSelectedText = "Disable Selected" - ${grid.component_studly}.computed.disableSelectedDisabled = function() { + ${grid.vue_component}.computed.disableSelectedDisabled = function() { if (this.disableSelectedSubmitting) { return true } @@ -542,7 +534,7 @@ return false } - ${grid.component_studly}.methods.disableSelectedSubmit = function() { + ${grid.vue_component}.methods.disableSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -560,12 +552,12 @@ % endif ## delete selected objects - % if master.set_deletable and master.has_perm('delete_set'): + % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'): - ${grid.component_studly}Data.deleteSelectedSubmitting = false - ${grid.component_studly}Data.deleteSelectedText = "Delete Selected" + ${grid.vue_component}Data.deleteSelectedSubmitting = false + ${grid.vue_component}Data.deleteSelectedText = "Delete Selected" - ${grid.component_studly}.computed.deleteSelectedDisabled = function() { + ${grid.vue_component}.computed.deleteSelectedDisabled = function() { if (this.deleteSelectedSubmitting) { return true } @@ -575,7 +567,7 @@ return false } - ${grid.component_studly}.methods.deleteSelectedSubmit = function() { + ${grid.vue_component}.methods.deleteSelectedSubmit = function() { let uuids = this.checkedRowUUIDs() if (!uuids.length) { alert("You must first select one or more objects to disable.") @@ -591,12 +583,12 @@ } % endif - % if master.bulk_deletable and master.has_perm('bulk_delete'): + % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'): - ${grid.component_studly}Data.deleteResultsSubmitting = false - ${grid.component_studly}Data.deleteResultsText = "Delete Results" + ${grid.vue_component}Data.deleteResultsSubmitting = false + ${grid.vue_component}Data.deleteResultsText = "Delete Results" - ${grid.component_studly}.computed.deleteResultsDisabled = function() { + ${grid.vue_component}.computed.deleteResultsDisabled = function() { if (this.deleteResultsSubmitting) { return true } @@ -606,7 +598,7 @@ return false } - ${grid.component_studly}.methods.deleteResultsSubmit = function() { + ${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 @@ -619,12 +611,12 @@ % endif - % if master.mergeable and master.has_perm('merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('merge'): - ${grid.component_studly}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" - ${grid.component_studly}Data.mergeFormSubmitting = false + ${grid.vue_component}Data.mergeFormButtonText = "Merge 2 ${model_title_plural}" + ${grid.vue_component}Data.mergeFormSubmitting = false - ${grid.component_studly}.methods.submitMergeForm = function() { + ${grid.vue_component}.methods.submitMergeForm = function() { this.mergeFormSubmitting = true this.mergeFormButtonText = "Working, please wait..." } diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index fe44caa9..a61020f3 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 master.touchable and master.has_perm('touch'): + % if getattr(master, 'touchable', False) 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 master.has_rows: + % if getattr(master, 'has_rows', False): <br /> % if rows_title: <h4 class="block is-size-4">${rows_title}</h4> @@ -241,7 +241,7 @@ </%def> <%def name="render_this_page_template()"> - % if master.has_rows: + % if getattr(master, 'has_rows', False): ## TODO: stop using |n filter ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} % endif @@ -318,7 +318,7 @@ ${parent.modify_whole_page_vars()} <script type="text/javascript"> - % if master.touchable and master.has_perm('touch'): + % if getattr(master, 'touchable', False) and master.has_perm('touch'): WholePageData.touchSubmitting = false @@ -340,7 +340,7 @@ ${parent.finalize_this_page_vars()} <script type="text/javascript"> - % if master.has_rows: + % if getattr(master, 'has_rows', False): TailboneGrid.data = function() { return TailboneGridData } Vue.component('tailbone-grid', TailboneGrid) % endif diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index c819050a..9339dfd5 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -3,7 +3,7 @@ <%def name="grid_tools()"> - % if master.mergeable and master.has_perm('request_merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): <b-button @click="showMergeRequest()" icon-pack="fas" icon-left="object-ungroup" @@ -65,7 +65,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - % if master.mergeable and master.has_perm('request_merge'): + % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): ${grid.component_studly}Data.mergeRequestShowDialog = false ${grid.component_studly}Data.mergeRequestRows = [] diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako index 184f2b91..d28d7558 100644 --- a/tailbone/templates/people/view.mako +++ b/tailbone/templates/people/view.mako @@ -9,7 +9,7 @@ <%def name="render_form()"> <div class="form"> - <tailbone-form v-on:make-user="makeUser"></tailbone-form> + <${form.vue_tagname} v-on:make-user="makeUser"></${form.vue_tagname}> </div> </%def> @@ -17,7 +17,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - TailboneForm.methods.clickMakeUser = function(event) { + ${form.vue_component}.methods.clickMakeUser = function(event) { this.$emit('make-user') } diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako index 1a0a4b7d..2ea289c8 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -15,7 +15,7 @@ <script type="text/x-template" id="find-principals-template"> <div> - ${h.form(request.current_route_url(), method='GET', **{'@submit': 'formSubmitting = true'})} + ${h.form(request.url, method='GET', **{'@submit': 'formSubmitting = true'})} <div style="margin-left: 10rem; max-width: 50%;"> ${h.hidden('permission_group', **{':value': 'selectedGroup'})} @@ -63,7 +63,7 @@ <b-field horizontal> <div class="buttons" style="margin-top: 1rem;"> <once-button tag="a" - href="${request.current_route_url(_query=None)}" + href="${request.path_url}" text="Reset Form"> </once-button> <b-button type="is-primary" diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index b0e43a37..f06b45f9 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -686,7 +686,7 @@ <h1 class="title"> ${index_title} </h1> - % if master.creatable and master.show_create_link and master.has_perm('create'): + % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" icon-left="plus" @@ -712,7 +712,7 @@ <h1 class="title"> ${h.link_to(instance_title, instance_url)} </h1> - % elif master.creatable and master.show_create_link and master.has_perm('create'): + % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): % if not request.matched_route.name.endswith('.create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" @@ -966,23 +966,23 @@ </%def> <%def name="render_crud_header_buttons()"> - % if master and master.viewing and not master.cloning: +% if master and master.viewing and not getattr(master, 'cloning', False): ## 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="${action_url('edit', instance)}" + <once-button tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> % endif - % if not master.cloning and master.cloneable and master.has_perm('clone'): - <once-button tag="a" href="${action_url('clone', instance)}" + % 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)}" 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="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -991,7 +991,7 @@ % else: ## viewing row % if instance_deletable and master.has_perm('delete_row'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -1000,13 +1000,13 @@ % endif % elif master and master.editing: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -1014,20 +1014,20 @@ % endif % elif master and master.deleting: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${action_url('edit', instance)}" + <once-button tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> % endif - % elif master and master.cloning: + % elif master and getattr(master, 'cloning', False): % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1e917902..f2d78b80 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1366,7 +1366,7 @@ class MasterView(View): txnid=txn.id) kwargs = { - 'component': 'versions-grid', + 'vue_tagname': 'versions-grid', 'ajax_data_url': self.get_action_url('revisions_data', obj), 'sortable': True, 'default_sortkey': 'changed', @@ -4421,7 +4421,7 @@ class MasterView(View): 'request': self.request, 'readonly': self.viewing, 'model_class': getattr(self, 'model_class', None), - 'action_url': self.request.current_route_url(_query=None), + 'action_url': self.request.path_url, 'assume_local_times': self.has_local_times, 'route_prefix': route_prefix, 'can_edit_help': self.can_edit_help(), diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py index b053453d..bb799efc 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('tailbone_permissions', {})) + self.request.registry.settings.get('wutta_permissions', {})) # sort groups, and permissions for each group, for UI's sake sorted_perms = sorted(permissions.items(), key=self.perm_sortkey) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index 09633c6e..b34b3673 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -287,8 +287,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. """ - # fetch full set of permissions registered in the app - permissions = self.request.registry.settings.get('tailbone_permissions', {}) + # get all known permissions from settings cache + permissions = self.request.registry.settings.get('wutta_permissions', {}) # admin user gets to manage all permissions if self.request.is_admin: diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 1012575a..f8bcb1b8 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -276,7 +276,7 @@ class UserView(PrincipalMasterView): # fs.confirm_password.attrs(autocomplete='new-password') if self.viewing: - permissions = self.request.registry.settings.get('tailbone_permissions', {}) + permissions = self.request.registry.settings.get('wutta_permissions', {}) f.set_renderer('permissions', PermissionsRenderer(request=self.request, permissions=permissions, include_anonymous=True, diff --git a/tests/__init__.py b/tests/__init__.py index 7dec63f0..40d8071f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -12,9 +12,6 @@ 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 new file mode 100644 index 00000000..e69de29b diff --git a/tests/forms/test_core.py b/tests/forms/test_core.py new file mode 100644 index 00000000..894d2302 --- /dev/null +++ b/tests/forms/test_core.py @@ -0,0 +1,153 @@ +# -*- 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 new file mode 100644 index 00000000..e69de29b diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py new file mode 100644 index 00000000..e6f9d675 --- /dev/null +++ b/tests/grids/test_core.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import MagicMock + +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, data=[], **kwargs): + kwargs.setdefault('request', self.request) + return mod.Grid(key, data=data, **kwargs) + + def test_basic(self): + grid = self.make_grid('foo') + self.assertIsInstance(grid, mod.Grid) + + 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_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) + columns = grid.get_vue_columns() + self.assertEqual(len(columns), 2) + self.assertEqual(columns[0]['field'], 'name') + self.assertEqual(columns[1]['field'], 'value') + + 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) diff --git a/tests/test_app.py b/tests/test_app.py index 2523c424..e16461ba 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,14 +3,14 @@ import os from unittest import TestCase -from sqlalchemy import create_engine +from pyramid.config import Configurator + +from wuttjamaican.testing import FileConfigTestCase -from rattail.config import RattailConfig from rattail.exceptions import ConfigurationError -from rattail.db import Session as RattailSession - -from tailbone import app -from tailbone.db import Session as TailboneSession +from rattail.config import RattailConfig +from tailbone import app as mod +from tests.util import DataTestCase class TestRattailConfig(TestCase): @@ -18,15 +18,34 @@ 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, app.make_rattail_config, {}) + self.assertRaises(ConfigurationError, mod.make_rattail_config, {}) # get a config object if path provided - result = app.make_rattail_config({'rattail.config': self.config_path}) + result = mod.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): + 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 new file mode 100644 index 00000000..4519e152 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8; -*- + +from tailbone import auth as mod diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..0cd1938c --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,12 @@ +# -*- 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 new file mode 100644 index 00000000..81bc2869 --- /dev/null +++ b/tests/test_subscribers.py @@ -0,0 +1,58 @@ +# -*- 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/util.py b/tests/util.py new file mode 100644 index 00000000..98d89ce0 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,75 @@ +# -*- 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'], + # '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_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 new file mode 100644 index 00000000..19321496 --- /dev/null +++ b/tests/views/test_master.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +from tailbone.views import master as mod +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']) diff --git a/tests/views/test_principal.py b/tests/views/test_principal.py new file mode 100644 index 00000000..2b31531c --- /dev/null +++ b/tests/views/test_principal.py @@ -0,0 +1,29 @@ +# -*- 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 new file mode 100644 index 00000000..0cdc724e --- /dev/null +++ b/tests/views/test_roles.py @@ -0,0 +1,80 @@ +# -*- 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_users.py b/tests/views/test_users.py new file mode 100644 index 00000000..4b94caf2 --- /dev/null +++ b/tests/views/test_users.py @@ -0,0 +1,33 @@ +# -*- 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) From dd176a5e9e43752ef87e16440e74f45ce303f1f3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 16:05:53 -0500 Subject: [PATCH 102/211] feat: add first wutta-based master, for PersonView still opt-in-only at this point, the traditional tailbone-native master is used by default. new wutta master is not feature complete yet. but at least things seem to render and form posts work okay.. when enabled, this uses a "completely" wutta-based stack for the view, grid and forms. but the underlying DB is of course rattail, and the templates are still traditional/tailbone. --- tailbone/views/people.py | 6 +- tailbone/views/wutta/__init__.py | 0 tailbone/views/wutta/people.py | 102 +++++++++++++++++++++++++++++++ tests/util.py | 2 + tests/views/test_people.py | 17 ++++++ tests/views/wutta/__init__.py | 0 tests/views/wutta/test_people.py | 47 ++++++++++++++ 7 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 tailbone/views/wutta/__init__.py create mode 100644 tailbone/views/wutta/people.py create mode 100644 tests/views/test_people.py create mode 100644 tests/views/wutta/__init__.py create mode 100644 tests/views/wutta/test_people.py diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 9b28b94d..94c85821 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -2187,4 +2187,8 @@ def defaults(config, **kwargs): def includeme(config): - defaults(config) + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.people') + else: + defaults(config) diff --git a/tailbone/views/wutta/__init__.py b/tailbone/views/wutta/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py new file mode 100644 index 00000000..44cc26d9 --- /dev/null +++ b/tailbone/views/wutta/people.py @@ -0,0 +1,102 @@ +# -*- 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 +""" + +from rattail.db.model import Person + +from wuttaweb.views import people as wutta +from tailbone.views import people as tailbone +from tailbone.db import Session + + +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', + ] + + form_fields = [ + 'first_name', + 'middle_name', + 'last_name', + 'display_name', + 'default_phone', + 'default_email', + # 'address', + # 'employee', + 'customers', + # 'members', + 'users', + ] + + def get_query(self, session=None): + """ """ + model = self.app.model + session = session or self.Session() + return session.query(model.Person)\ + .order_by(model.Person.display_name) + + def configure_form(self, f): + """ """ + super().configure_form(f) + + # default_phone + f.set_required('default_phone', False) + + # default_email + f.set_required('default_email', False) + + # customers + if self.creating or self.editing: + f.remove('customers') + + +def defaults(config, **kwargs): + base = globals() + + kwargs.setdefault('PersonView', base['PersonView']) + tailbone.defaults(config, **kwargs) + + +def includeme(config): + defaults(config) diff --git a/tests/util.py b/tests/util.py index 98d89ce0..3aa04f5e 100644 --- a/tests/util.py +++ b/tests/util.py @@ -43,6 +43,8 @@ class WebTestCase(DataTestCase): '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') diff --git a/tests/views/test_people.py b/tests/views/test_people.py new file mode 100644 index 00000000..f85577e7 --- /dev/null +++ b/tests/views/test_people.py @@ -0,0 +1,17 @@ +# -*- 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/wutta/__init__.py b/tests/views/wutta/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py new file mode 100644 index 00000000..7795d641 --- /dev/null +++ b/tests/views/wutta/test_people.py @@ -0,0 +1,47 @@ +# -*- 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_form(self): + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + self.session.commit() + view = self.make_view() + + # customers 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('customers', form.fields) + view.configure_form(form) + self.assertIn('customers', form) + + # customers 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('customers', form.fields) + view.configure_form(form) + self.assertNotIn('customers', form) From bab09e3fe73af86f3ecb9501ac35f26a270ff35a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 16:22:35 -0500 Subject: [PATCH 103/211] =?UTF-8?q?bump:=20version=200.15.6=20=E2=86=92=20?= =?UTF-8?q?0.16.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3836ff08..401c1b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index e515a0d0..dc0887d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.15.6" +version = "0.16.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.2.0", + "WuttaWeb>=0.7.0", "zope.sqlalchemy>=1.5", ] From 1cacfab2a63a5d9b6216a3d1173e9efbc7919848 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 18:44:14 -0500 Subject: [PATCH 104/211] fix: tweak template for `people/view_profile` per wutta compat wutta has the view defined but it returns minimal context --- tailbone/templates/people/view_profile.mako | 26 +++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako index 8044f7c6..cdb6c5cc 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} + ${dynamic_content_title or str(instance)} </%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}"> + <b-field horizontal label="${customer_key_label or 'TODO: Customer Key'}"> {{ customer._key }} </b-field> @@ -1996,7 +1996,9 @@ <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, @@ -2447,7 +2449,9 @@ <script type="text/javascript"> let CustomerTabData = { + % if hasattr(master, 'profile_tab_customer'): refreshTabURL: '${url('people.profile_tab_customer', uuid=person.uuid)}', + % endif customers: [], } @@ -2521,7 +2525,9 @@ <script type="text/javascript"> let EmployeeTabData = { + % if hasattr(master, 'profile_tab_employee'): refreshTabURL: '${url('people.profile_tab_employee', uuid=person.uuid)}', + % endif employee: {}, employeeHistory: [], @@ -2756,7 +2762,9 @@ <script type="text/javascript"> let NotesTabData = { + % if hasattr(master, 'profile_tab_notes'): refreshTabURL: '${url('people.profile_tab_notes', uuid=person.uuid)}', + % endif notes: [], noteTypeOptions: [], @@ -2920,7 +2928,9 @@ <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'): @@ -2976,7 +2986,9 @@ 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, @@ -3015,13 +3027,13 @@ let ProfileInfoData = { activeTab: location.hash ? location.hash.substring(1) : 'personal', - tabchecks: ${json.dumps(tabchecks)|n}, + tabchecks: ${json.dumps(tabchecks or {})|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}, + 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}, % if request.has_perm('people_profile.view_versions'): loadingRevisions: false, From 53040dc6befed83aaf691000c951b2678669a499 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 20:29:36 -0500 Subject: [PATCH 105/211] fix: update references to `get_class_hierarchy()` per upstream changes --- tailbone/views/master.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index f2d78b80..0d322da3 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -39,8 +39,9 @@ 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, get_class_hierarchy +from rattail.util import simple_error from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter from rattail.excel import ExcelWriter From 7f0c571a446a71520e70d666e11f6b9be5aeecb8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 21:12:34 -0500 Subject: [PATCH 106/211] fix: improve wutta People view a bit try to behave more like traditional tailbone, for the few things supported so far. taking a conservative approach here for now since probably other things are more pressing. --- tailbone/views/wutta/people.py | 85 ++++++++++++++++++++++++-------- tests/views/wutta/test_people.py | 52 ++++++++++++++++--- 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index 44cc26d9..c92e34ae 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -24,11 +24,14 @@ Person Views """ -from rattail.db.model import Person +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 class PersonView(wutta.PersonView): @@ -42,9 +45,9 @@ class PersonView(wutta.PersonView): model_class = Person Session = Session - # labels = { - # 'display_name': "Full Name", - # } + labels = { + 'display_name': "Full Name", + } grid_columns = [ 'display_name', @@ -60,15 +63,16 @@ class PersonView(wutta.PersonView): 'middle_name', 'last_name', 'display_name', - 'default_phone', - 'default_email', + 'phone', + 'email', + # TODO # 'address', - # 'employee', - 'customers', - # 'members', - 'users', ] + ############################## + # CRUD methods + ############################## + def get_query(self, session=None): """ """ model = self.app.model @@ -76,25 +80,64 @@ class PersonView(wutta.PersonView): return session.query(model.Person)\ .order_by(model.Person.display_name) + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # display_name + g.set_link('display_name') + + # first_name + g.set_link('first_name') + + # last_name + g.set_link('last_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) - # default_phone - f.set_required('default_phone', False) - - # default_email - f.set_required('default_email', False) - - # customers + # email if self.creating or self.editing: - f.remove('customers') + 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): - base = globals() - - kwargs.setdefault('PersonView', base['PersonView']) + kwargs.setdefault('PersonView', PersonView) tailbone.defaults(config, **kwargs) diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py index 7795d641..f178a64f 100644 --- a/tests/views/wutta/test_people.py +++ b/tests/views/wutta/test_people.py @@ -23,6 +23,19 @@ class TestPersonView(WebTestCase): 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.User(username='barney') @@ -30,18 +43,45 @@ class TestPersonView(WebTestCase): self.session.commit() view = self.make_view() - # customers field remains when viewing + # 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('customers', form.fields) + self.assertIn('email', form.fields) view.configure_form(form) - self.assertIn('customers', form) + self.assertIn('email', form) - # customers field removed when editing + # 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('customers', form.fields) + self.assertIn('email', form.fields) view.configure_form(form) - self.assertNotIn('customers', 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) From bbc2c584ec030b69e7c8f711d7d3e1a31a18bceb Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 21:16:53 -0500 Subject: [PATCH 107/211] =?UTF-8?q?bump:=20version=200.16.0=20=E2=86=92=20?= =?UTF-8?q?0.16.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 401c1b25..f532ae03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index dc0887d4..69c35a68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.16.0" +version = "0.16.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.17.11", + "rattail[db,bouncer]>=0.18.1", "sa-filters", "simplejson", "transaction", From da0f6bd5e10a6f623d47d017ee862ea7e455faa2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 23:12:02 -0500 Subject: [PATCH 108/211] feat: use wuttaweb for `get_liburl()` logic thankfully this is already handled and we can remove from tailbone. although this adds some new cruft as well, to handle auto-migrating any existing liburl config for apps. eventually once all apps have migrated to new settings we can remove the prefix from our calls here but also in wuttaweb signature --- tailbone/helpers.py | 4 +- tailbone/templates/appinfo/configure.mako | 8 +- tailbone/templates/base.mako | 10 +- .../templates/themes/butterball/base.mako | 14 +- tailbone/util.py | 162 +++--------------- tailbone/views/settings.py | 131 +++++++------- tests/views/test_settings.py | 10 ++ 7 files changed, 110 insertions(+), 229 deletions(-) create mode 100644 tests/views/test_settings.py diff --git a/tailbone/helpers.py b/tailbone/helpers.py index d4065cc5..23988423 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -36,11 +36,11 @@ from rattail.db.util import maxlen from webhelpers2.html import * from webhelpers2.html.tags import * +from wuttaweb.util import get_liburl from tailbone.util import (csrf_token, get_csrf_token, pretty_datetime, raw_datetime, render_markdown, - route_exists, - get_liburl) + route_exists) def pretty_date(date): diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 280b5cb9..aab180c4 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -149,8 +149,8 @@ </${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'])})} + ${h.hidden('wuttaweb.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.libver.{}']".format(weblib['key'])})} + ${h.hidden('wuttaweb.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.liburl.{}']".format(weblib['key'])})} % endfor <${b}-modal has-modal-card @@ -236,8 +236,8 @@ 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.simpleSettings[`wuttaweb.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion + this.simpleSettings[`wuttaweb.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL this.settingsNeedSaved = true this.editWebLibraryShowDialog = false diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 6811397b..27e900e4 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -122,16 +122,16 @@ </%def> <%def name="vuejs()"> - ${h.javascript_link(h.get_liburl(request, 'vue'))} - ${h.javascript_link(h.get_liburl(request, 'vue_resource'))} + ${h.javascript_link(h.get_liburl(request, 'vue', prefix='tailbone'))} + ${h.javascript_link(h.get_liburl(request, 'vue_resource', prefix='tailbone'))} </%def> <%def name="buefy()"> - ${h.javascript_link(h.get_liburl(request, 'buefy'))} + ${h.javascript_link(h.get_liburl(request, 'buefy', prefix='tailbone'))} </%def> <%def name="fontawesome()"> - <script defer src="${h.get_liburl(request, 'fontawesome')}"></script> + <script defer src="${h.get_liburl(request, 'fontawesome', prefix='tailbone')}"></script> </%def> <%def name="extra_javascript()"></%def> @@ -171,7 +171,7 @@ ${h.stylesheet_link(user_css)} % else: ## upstream Buefy CSS - ${h.stylesheet_link(h.get_liburl(request, 'buefy.css'))} + ${h.stylesheet_link(h.get_liburl(request, 'buefy.css', prefix='tailbone'))} % endif </%def> diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index f06b45f9..306b3430 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -71,12 +71,12 @@ { ## TODO: eventually version / url should be configurable "imports": { - "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')}" + "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')}" } } </script> @@ -92,7 +92,7 @@ % if user_css: ${h.stylesheet_link(user_css)} % else: - ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css'))} + ${h.stylesheet_link(h.get_liburl(request, 'bb_oruga_bulma_css', prefix='tailbone'))} % endif </%def> diff --git a/tailbone/util.py b/tailbone/util.py index eb6fb8a8..594fd69b 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -39,7 +39,9 @@ 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 +from wuttaweb.util import (get_form_data as wutta_get_form_data, + get_libver as wutta_get_libver, + get_liburl as wutta_get_liburl) log = logging.getLogger(__name__) @@ -103,154 +105,32 @@ def get_global_search_options(request): return options -def get_libver(request, key, fallback=True, default_only=False): +def get_libver(request, key, fallback=True, default_only=False): # pragma: no cover """ - Return the appropriate URL for the library identified by ``key``. + DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_libver()` + instead. """ - config = request.rattail_config + warnings.warn("tailbone.util.get_libver() is deprecated; " + "please use wuttaweb.util.get_libver() instead", + DeprecationWarning, stacklevel=2) - 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' + return wutta_get_libver(request, key, prefix='tailbone', + configured_only=not fallback, + default_only=default_only) -def get_liburl(request, key, fallback=True): +def get_liburl(request, key, fallback=True): # pragma: no cover """ - Return the appropriate URL for the library identified by ``key``. + DEPRECATED - use :func:`wuttaweb:wuttaweb.util.get_liburl()` + instead. """ - config = request.rattail_config + warnings.warn("tailbone.util.get_liburl() is deprecated; " + "please use wuttaweb.util.get_liburl() instead", + DeprecationWarning, stacklevel=2) - 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' + return wutta_get_liburl(request, key, prefix='tailbone', + configured_only=not fallback, + default_only=False) def pretty_datetime(config, value): diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 8d389530..9d7f6e02 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -24,24 +24,23 @@ Settings Views """ +import json import os import re import subprocess import sys from collections import OrderedDict -import json +import colander from rattail.db.model import Setting from rattail.settings import Setting as AppSetting from rattail.util import import_module_path -import colander - from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView, View -from tailbone.util import get_libver, get_liburl +from wuttaweb.util import get_libver, get_liburl class AppInfoView(MasterView): @@ -99,10 +98,9 @@ class AppInfoView(MasterView): kwargs['configure_button_title'] = "Configure App" return kwargs - def configure_get_context(self, **kwargs): - context = super().configure_get_context(**kwargs) - - weblibs = OrderedDict([ + def get_weblibs(self): + """ """ + return OrderedDict([ ('vue', "Vue"), ('vue_resource', "vue-resource"), ('buefy', "Buefy"), @@ -117,6 +115,12 @@ class AppInfoView(MasterView): ('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"), ]) + def configure_get_context(self, **kwargs): + """ """ + context = super().configure_get_context(**kwargs) + simple_settings = context['simple_settings'] + weblibs = self.get_weblibs() + for key in weblibs: title = weblibs[key] weblibs[key] = { @@ -125,19 +129,33 @@ class AppInfoView(MasterView): # 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), + 'configured_version': get_libver(self.request, key, + prefix='tailbone', + configured_only=True), + 'configured_url': get_liburl(self.request, key, + prefix='tailbone', + configured_only=True), # these are for informational purposes only - 'default_version': get_libver(self.request, key, default_only=True), - 'live_url': get_liburl(self.request, key), + 'default_version': get_libver(self.request, key, + prefix='tailbone', + default_only=True), + 'live_url': get_liburl(self.request, key, + prefix='tailbone'), } + # TODO: this is only needed to migrate legacy settings to + # use the newer wutaweb setting names + url = simple_settings[f'wuttaweb.liburl.{key}'] + if not url and weblibs[key]['configured_url']: + simple_settings[f'wuttaweb.liburl.{key}'] = weblibs[key]['configured_url'] + context['weblibs'] = list(weblibs.values()) return context def configure_get_simple_settings(self): - return [ + """ """ + simple_settings = [ # basics {'section': 'rattail', @@ -167,63 +185,6 @@ class AppInfoView(MasterView): # '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', @@ -233,6 +194,36 @@ class AppInfoView(MasterView): ] + def getval(key): + return self.config.get(f'tailbone.{key}') + + weblibs = self.get_weblibs() + for key, title in weblibs.items(): + + simple_settings.append({ + 'section': 'wuttaweb', + 'option': f"libver.{key}", + 'default': getval(f"libver.{key}"), + }) + simple_settings.append({ + 'section': 'wuttaweb', + 'option': f"liburl.{key}", + 'default': getval(f"liburl.{key}"), + }) + + # nb. these are no longer used (deprecated), but we keep + # them defined here so the tool auto-deletes them + simple_settings.append({ + 'section': 'tailbone', + 'option': f"libver.{key}", + }) + simple_settings.append({ + 'section': 'tailbone', + 'option': f"liburl.{key}", + }) + + return simple_settings + class SettingView(MasterView): """ diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py new file mode 100644 index 00000000..b8523729 --- /dev/null +++ b/tests/views/test_settings.py @@ -0,0 +1,10 @@ +# -*- 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') From bbd98e7b2f0ec57c3b4ffcd0b30786e8f0449504 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 23:15:25 -0500 Subject: [PATCH 109/211] =?UTF-8?q?bump:=20version=200.16.1=20=E2=86=92=20?= =?UTF-8?q?0.17.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f532ae03..5724e685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.17.0 (2024-08-15) + +### Feat + +- use wuttaweb for `get_liburl()` logic + ## v0.16.1 (2024-08-15) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 69c35a68..31c7ef8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.16.1" +version = "0.17.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.7.0", + "WuttaWeb>=0.8.1", "zope.sqlalchemy>=1.5", ] From 09612b1921af0a7b3bb7141381c3bb861b4d64ab Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 15 Aug 2024 23:46:58 -0500 Subject: [PATCH 110/211] fix: fix some more wutta compat for base template missed those earlier --- tailbone/templates/base.mako | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 27e900e4..3a12859e 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -280,7 +280,7 @@ <span class="header-text"> ${index_title} </span> - % if master.creatable and master.show_create_link and master.has_perm('create'): + % if master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" icon-left="plus" @@ -306,7 +306,7 @@ <span class="header-text"> ${h.link_to(instance_title, instance_url)} </span> - % elif master.creatable and master.show_create_link and master.has_perm('create'): + % elif master.creatable and getattr(master, 'show_create_link', True) and master.has_perm('create'): % if not request.matched_route.name.endswith('.create'): <once-button type="is-primary" tag="a" href="${url('{}.create'.format(route_prefix))}" From 1b78bd617c09f229a40161c14d07d883159b3668 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Aug 2024 11:56:12 -0500 Subject: [PATCH 111/211] feat: inherit most logic from wuttaweb, for GridAction --- tailbone/grids/core.py | 65 ++++++++++---------------- tailbone/templates/grids/b-table.mako | 11 ++--- tailbone/templates/grids/complete.mako | 8 +--- tailbone/views/master.py | 8 +++- tailbone/views/people.py | 2 +- tailbone/views/purchasing/receiving.py | 4 +- tailbone/views/roles.py | 2 +- tests/grids/test_core.py | 17 +++++++ tests/views/test_master.py | 9 ++++ 9 files changed, 65 insertions(+), 61 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 3f1769cf..b9254c18 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -38,6 +38,7 @@ from pyramid.renderers import render from webhelpers2.html import HTML, tags from paginate_sqlalchemy import SqlalchemyOrmPage +from wuttaweb.grids import GridAction as WuttaGridAction from . import filters as gridfilters from tailbone.db import Session from tailbone.util import raw_datetime @@ -1801,18 +1802,20 @@ class Grid: return False -class GridAction(object): +class GridAction(WuttaGridAction): """ - Represents an action available to a grid. This is used to construct the - 'actions' column when rendering the grid. + Represents a "row action" hyperlink within a grid context. - :param key: Key for the action (e.g. ``'edit'``), unique within - the grid. + This is a subclass of + :class:`wuttaweb:wuttaweb.grids.base.GridAction`. - :param label: Label to be displayed for the action. If not set, - will be a capitalized version of ``key``. + .. warning:: - :param icon: Icon name for the action. + 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 click_handler: Optional JS click handler for the action. This value will be rendered as-is within the final grid @@ -1824,41 +1827,23 @@ class GridAction(object): * ``$emit('do-something', props.row)`` """ - 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 + 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) + 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/templates/grids/b-table.mako b/tailbone/templates/grids/b-table.mako index 632193b5..da9f2aae 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.main_actions or grid.more_actions: + % if grid.actions: <${b}-table-column field="actions" label="Actions" v-slot="props"> - % for action in grid.main_actions: + % for action in grid.actions: <a :href="props.row._action_url_${action.key}" % if action.link_class: class="${action.link_class}" @@ -68,12 +68,7 @@ @click.prevent="${action.click_handler}" % endif > - % if request.use_oruga: - <o-icon icon="${action.icon}" /> - % else: - <i class="fas fa-${action.icon}"></i> - % endif - ${action.label} + ${action.render_icon_and_label()} </a> % endfor diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index fc48916b..93bb6c26 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -163,13 +163,7 @@ target="${action.target}" % endif > - % 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 + ${action.render_icon_and_label()} </a> % endfor diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 0d322da3..097cb229 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -3220,14 +3220,18 @@ class MasterView(View): def make_action(self, key, url=None, factory=None, **kwargs): """ - Make a new :class:`GridAction` instance for the current grid. + 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()`. """ 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(key, url=url, **kwargs) + return factory(self.request, key, url=url, **kwargs) def get_action_route_kwargs(self, obj): """ diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 94c85821..163a9a52 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -552,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.main_actions.append(grids.GridAction('view', icon='eye', url=url)) + g.main_actions.append(self.make_action('view', icon='eye', url=url)) g.load_settings() g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 55936184..0a305f0a 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -40,7 +40,7 @@ from webhelpers2.html import tags, HTML from wuttaweb.util import get_form_data -from tailbone import forms, grids +from tailbone import forms from tailbone.views.purchasing import PurchasingBatchView @@ -1031,7 +1031,7 @@ 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 = grids.GridAction('transform', + transform = self.make_action('transform', icon='shuffle', label="Transform to Unit", url=self.transform_unit_url) diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index b34b3673..fb834479 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -363,7 +363,7 @@ class RoleView(PrincipalMasterView): if role.users: users = sorted(role.users, key=lambda u: u.username) actions = [ - grids.GridAction('view', icon='zoomin', + self.make_action('view', icon='zoomin', url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid)) ] kwargs['users'] = grids.Grid(None, users, ['username', 'active'], diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index e6f9d675..0a8d5d66 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -137,3 +137,20 @@ class TestGrid(WebTestCase): # 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/views/test_master.py b/tests/views/test_master.py index 19321496..572875a0 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -3,6 +3,7 @@ from unittest.mock import patch from tailbone.views import master as mod +from wuttaweb.grids import GridAction from tests.util import WebTestCase @@ -24,3 +25,11 @@ class TestMasterView(WebTestCase): # 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) From f7641218cb44c6ad18d6672361d1f1243c05e397 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Aug 2024 11:56:54 -0500 Subject: [PATCH 112/211] fix: avoid route error in user view, when using wutta people view kind of a temporary edge case here, can eventually change it back --- tailbone/views/users.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index f8bcb1b8..9eae74d8 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -208,9 +208,13 @@ class UserView(PrincipalMasterView): person_display = str(person) elif self.editing: person_display = str(user.person or '') - people_url = self.request.route_url('people.autocomplete') - f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget( - field_display=person_display, service_url=people_url)) + 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)) f.set_validator('person_uuid', self.valid_person) f.set_label('person_uuid', "Person") From 2a0b6da2f9169c22c099ca2c367a3ab2d89fa6e2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Aug 2024 14:34:50 -0500 Subject: [PATCH 113/211] feat: inherit from wutta base class for Grid --- tailbone/grids/core.py | 241 ++++++++++--------------- tailbone/views/batch/core.py | 8 +- tailbone/views/batch/pos.py | 1 + tailbone/views/customers.py | 19 +- tailbone/views/custorders/items.py | 1 + tailbone/views/custorders/orders.py | 71 ++++---- tailbone/views/departments.py | 8 +- tailbone/views/email.py | 2 +- tailbone/views/employees.py | 3 +- tailbone/views/master.py | 48 +++-- tailbone/views/members.py | 3 +- tailbone/views/people.py | 19 +- tailbone/views/poser/reports.py | 2 +- tailbone/views/principal.py | 6 +- tailbone/views/products.py | 28 +-- tailbone/views/purchasing/batch.py | 4 +- tailbone/views/purchasing/receiving.py | 18 +- tailbone/views/reports.py | 12 +- tailbone/views/roles.py | 15 +- tailbone/views/tempmon/core.py | 6 +- tailbone/views/trainwreck/base.py | 12 +- tailbone/views/users.py | 15 +- tests/grids/test_core.py | 49 ++++- 23 files changed, 317 insertions(+), 274 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index b9254c18..a5617215 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -38,7 +38,7 @@ from pyramid.renderers import render from webhelpers2.html import HTML, tags from paginate_sqlalchemy import SqlalchemyOrmPage -from wuttaweb.grids import GridAction as WuttaGridAction +from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction from . import filters as gridfilters from tailbone.db import Session from tailbone.util import raw_datetime @@ -61,7 +61,7 @@ class FieldList(list): self.insert(i + 1, newfield) -class Grid: +class Grid(WuttaGrid): """ Core grid class. In sore need of documentation. @@ -186,32 +186,59 @@ class Grid: grid.row_uuid_getter = fake_uuid """ - 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, - vue_tagname=None, - expose_direct_link=False, - **kwargs): + 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='#', + 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, + expose_direct_link=False, + **kwargs, + ): + if kwargs.get('component'): + warnings.warn("component param is deprecated for Grid(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('vue_tagname', kwargs.pop('component')) - 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() + # TODO: pretty sure this should go away? + kwargs.setdefault('vue_tagname', 'tailbone-grid') + + kwargs['key'] = key + kwargs['data'] = data + super().__init__(request, **kwargs) self.model_title = model_title if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'): @@ -224,15 +251,13 @@ class Grid: if not self.model_title_plural: self.model_title_plural = '{}s'.format(self.model_title) + self.width = width self.enums = enums or {} - - self.labels = labels or {} self.assume_local_times = assume_local_times - self.renderers = self.make_default_renderers(renderers or {}) + self.renderers = self.make_default_renderers(self.renderers) 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 {} @@ -263,8 +288,6 @@ class Grid: 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: @@ -274,29 +297,22 @@ class Grid: else: self.ajax_data_url = '' - # vue_tagname - self.vue_tagname = vue_tagname - if not self.vue_tagname and kwargs.get('component'): - warnings.warn("component kwarg is deprecated for Grid(); " - "please use vue_tagname param instead", + 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.vue_tagname = kwargs['component'] - if not self.vue_tagname: - self.vue_tagname = 'tailbone-grid' + 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.expose_direct_link = expose_direct_link self._whgrid_kwargs = kwargs - @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): """ @@ -317,34 +333,6 @@ class Grid: DeprecationWarning, stacklevel=2) return self.vue_component - @property - def actions(self): - """ """ - actions = [] - if self.main_actions: - actions.extend(self.main_actions) - if self.more_actions: - actions.extend(self.more_actions) - return actions - - 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()") - - mapper = orm.class_mapper(self.model_class) - return [prop.key for prop in mapper.iterate_properties] - - 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): """ This *removes* a column from the grid, altogether. @@ -377,9 +365,6 @@ class Grid: 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) @@ -430,24 +415,22 @@ class Grid: self.filters.pop(key, None) def set_label(self, key, label, column_only=False): - self.labels[key] = label + """ + Set/override the label for a column. + + This overrides + :meth:`~wuttaweb:wuttaweb.grids.base.Grid.set_label()` to add + the following params: + + :param column_only: Boolean indicating whether the label + should be applied *only* to the column header (if + ``True``), vs. applying also to the filter (if ``False``). + """ + super().set_label(key, label) + if not column_only and key in self.filters: self.filters[key].label = label - def 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: self.click_handlers[key] = handler @@ -457,9 +440,6 @@ class Grid: 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. @@ -1450,22 +1430,13 @@ class Grid: 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.main_actions: + for action in self.actions: if action.key == 'view': - 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 + return action.click_handler def set_filters_sequence(self, filters, only=False): """ @@ -1561,26 +1532,21 @@ class Grid: 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_actions(self, row, i): # pragma: no cover + """ """ + warnings.warn("grid.render_actions() is deprecated!", + DeprecationWarning, stacklevel=2) + + 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) + + def render_action(self, action, row, i): # pragma: no cover + """ """ + warnings.warn("grid.render_action() is deprecated!", + DeprecationWarning, stacklevel=2) - 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} @@ -1786,21 +1752,10 @@ class Grid: 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.main_actions + self.more_actions): + for action in self.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): """ diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index f4f74a34..5dd7b548 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -186,7 +186,9 @@ class BatchMasterView(MasterView): breakdown = self.make_status_breakdown(batch) factory = self.get_grid_factory() - g = factory('batch_row_status_breakdown', [], + g = factory(self.request, + key='batch_row_status_breakdown', + data=[], columns=['title', 'count']) g.set_click_handler('title', "autoFilterStatus(props.row)") kwargs['status_breakdown_data'] = breakdown @@ -693,7 +695,7 @@ class BatchMasterView(MasterView): batch = self.get_instance() # TODO: most of this logic is copied from MasterView, should refactor/merge somehow... - if 'main_actions' not in kwargs: + if 'actions' not in kwargs: actions = [] # view action @@ -714,7 +716,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['main_actions'] = actions + kwargs['actions'] = actions return super().make_row_grid_kwargs(**kwargs) diff --git a/tailbone/views/batch/pos.py b/tailbone/views/batch/pos.py index 11031353..b6fef6c8 100644 --- a/tailbone/views/batch/pos.py +++ b/tailbone/views/batch/pos.py @@ -195,6 +195,7 @@ class POSBatchView(BatchMasterView): factory = self.get_grid_factory() g = factory( + self.request, key=f'{route_prefix}.taxes', data=[], columns=[ diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index 2958a98a..7e49ccef 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -208,8 +208,7 @@ 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.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) g.set_link('name') g.set_link('person') @@ -471,7 +470,8 @@ class CustomerView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.people'.format(route_prefix), + self.request, + key=f'{route_prefix}.people', data=[], columns=[ 'shopper_number', @@ -500,7 +500,8 @@ class CustomerView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.people'.format(route_prefix), + self.request, + key=f'{route_prefix}.people', data=[], columns=[ 'full_name', @@ -512,13 +513,13 @@ class CustomerView(MasterView): ) if self.request.has_perm('people.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('people.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) if self.people_detachable and self.has_perm('detach_person'): - 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)")) + 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)")) 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 d8e39f55..e7edf3aa 100644 --- a/tailbone/views/custorders/items.py +++ b/tailbone/views/custorders/items.py @@ -385,6 +385,7 @@ 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 f76d4d93..b1a9831a 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -29,13 +29,12 @@ import logging from sqlalchemy import orm -from rattail.db import model -from rattail.util import pretty_quantity, simple_error +from rattail.db.model import CustomerOrder, CustomerOrderItem +from rattail.util import 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 @@ -46,7 +45,7 @@ class CustomerOrderView(MasterView): """ Master view for customer orders """ - model_class = model.CustomerOrder + model_class = CustomerOrder route_prefix = 'custorders' editable = False configurable = True @@ -80,7 +79,7 @@ class CustomerOrderView(MasterView): ] has_rows = True - model_row_class = model.CustomerOrderItem + model_row_class = CustomerOrderItem rows_viewable = False row_labels = { @@ -116,15 +115,17 @@ class CustomerOrderView(MasterView): ] def __init__(self, request): - super(CustomerOrderView, self).__init__(request) + super().__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') @@ -163,7 +164,7 @@ class CustomerOrderView(MasterView): return f"#{order.id} for {order.customer or order.person}" def configure_form(self, f): - super(CustomerOrderView, self).configure_form(f) + super().configure_form(f) order = f.model_instance f.set_readonly('id') @@ -233,6 +234,7 @@ 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) @@ -240,11 +242,13 @@ class CustomerOrderView(MasterView): return item.order def make_row_grid_kwargs(self, **kwargs): - kwargs = super(CustomerOrderView, self).make_row_grid_kwargs(**kwargs) + kwargs = super().make_row_grid_kwargs(**kwargs) - assert not kwargs['main_actions'] - kwargs['main_actions'].append( - self.make_action('view', icon='eye', url=self.row_view_action_url)) + actions = kwargs.get('actions', []) + if not actions: + actions.append(self.make_action('view', icon='eye', + url=self.row_view_action_url)) + kwargs['actions'] = actions return kwargs @@ -253,7 +257,7 @@ class CustomerOrderView(MasterView): return self.request.route_url('custorders.items.view', uuid=item.uuid) def configure_row_grid(self, g): - super(CustomerOrderView, self).configure_row_grid(g) + super().configure_row_grid(g) app = self.get_rattail_app() handler = app.get_batch_handler( 'custorder', @@ -423,6 +427,7 @@ 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)\ @@ -488,6 +493,7 @@ 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"} @@ -508,6 +514,7 @@ 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 @@ -662,6 +669,7 @@ 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"} @@ -725,8 +733,7 @@ class CustomerOrderView(MasterView): return app.render_currency(obj.unit_price) def normalize_row(self, row): - app = self.get_rattail_app() - products_handler = app.get_products_handler() + products_handler = self.app.get_products_handler() data = { 'uuid': row.uuid, @@ -742,20 +749,20 @@ class CustomerOrderView(MasterView): 'product_size': row.product_size, 'product_weighed': row.product_weighed, - '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), + '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), 'order_uom': row.order_uom, 'order_uom_choices': self.uom_choices_for_row(row), - 'discount_percent': pretty_quantity(row.discount_percent), + 'discount_percent': self.app.render_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': app.render_currency(row.total_price), + 'total_price_display': self.app.render_currency(row.total_price), 'status_code': row.status_code, 'status_text': row.status_text, @@ -763,15 +770,15 @@ class CustomerOrderView(MasterView): if row.unit_regular_price: data['unit_regular_price'] = float(row.unit_regular_price) - data['unit_regular_price_display'] = app.render_currency(row.unit_regular_price) + data['unit_regular_price_display'] = self.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'] = app.render_currency(row.unit_sale_price) + data['unit_sale_price_display'] = self.app.render_currency(row.unit_sale_price) if row.sale_ends: - sale_ends = app.localtime(row.sale_ends, from_utc=True).date() + sale_ends = self.app.localtime(row.sale_ends, from_utc=True).date() data['sale_ends'] = str(sale_ends) - data['sale_ends_display'] = app.render_date(sale_ends) + data['sale_ends_display'] = self.app.render_date(sale_ends) if row.unit_sale_price and row.unit_price == row.unit_sale_price: data['pricing_reflects_sale'] = True @@ -808,12 +815,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'] = app.render_currency(case_price) + data['case_price_display'] = self.app.render_currency(case_price) if self.batch_handler.product_price_may_be_questionable(): data['price_needs_confirmation'] = row.price_needs_confirmation - key = app.get_product_key_field() + key = self.app.get_product_key_field() if key == 'upc': data['product_key'] = data['product_upc_pretty'] elif key == 'item_id': @@ -837,7 +844,7 @@ class CustomerOrderView(MasterView): case_qty = unit_qty = '??' else: case_qty = data['case_quantity'] - unit_qty = pretty_quantity(row.order_quantity * row.case_quantity) + unit_qty = self.app.render_quantity(row.order_quantity * row.case_quantity) data.update({ 'order_quantity_display': "{} {} (× {} {} = {} {})".format( data['order_quantity'], @@ -850,14 +857,14 @@ class CustomerOrderView(MasterView): else: data.update({ 'order_quantity_display': "{} {}".format( - pretty_quantity(row.order_quantity), + self.app.render_quantity(row.order_quantity), self.enum.UNIT_OF_MEASURE[unit_uom]), }) return data def add_item(self, batch, data): - app = self.get_rattail_app() + model = self.app.model order_quantity = decimal.Decimal(data.get('order_quantity') or '0') order_uom = data.get('order_uom') @@ -888,7 +895,7 @@ class CustomerOrderView(MasterView): pending_info = dict(data['pending_product']) if 'upc' in pending_info: - pending_info['upc'] = app.make_gpc(pending_info['upc']) + pending_info['upc'] = self.app.make_gpc(pending_info['upc']) for field in ('unit_cost', 'regular_price_amount', 'case_size'): if field in pending_info: @@ -917,6 +924,7 @@ 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"} @@ -975,6 +983,7 @@ 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/departments.py b/tailbone/views/departments.py index 6ee1439f..47de8dca 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( - key='{}.employees'.format(route_prefix), - request=self.request, + self.request, + key=f'{route_prefix}.employees', data=[], columns=[ 'first_name', @@ -140,9 +140,9 @@ class DepartmentView(MasterView): ) if self.request.has_perm('employees.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('employees.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.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 4014c05e..a99e8553 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -141,7 +141,7 @@ class EmailSettingView(MasterView): # toggle hidden if self.has_perm('configure'): - g.main_actions.append( + g.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 f4f99058..debd8fcb 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -167,8 +167,7 @@ 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.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.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 097cb229..8f65fc88 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -392,9 +392,8 @@ class MasterView(View): if columns is None: columns = self.get_grid_columns() - kwargs.setdefault('request', self.request) kwargs = self.make_grid_kwargs(**kwargs) - grid = factory(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_grid(grid) grid.load_settings() return grid @@ -454,10 +453,26 @@ class MasterView(View): if self.sortable or self.pageable or self.filterable: defaults['expose_direct_link'] = True - 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 + 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 + defaults.update(kwargs) return defaults @@ -548,9 +563,8 @@ 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(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_row_grid(grid) grid.load_settings() return grid @@ -577,7 +591,7 @@ class MasterView(View): if self.rows_default_pagesize: defaults['default_pagesize'] = self.rows_default_pagesize - if self.has_rows and 'main_actions' not in defaults: + if self.has_rows and 'actions' not in defaults: actions = [] # view action @@ -595,7 +609,7 @@ class MasterView(View): actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) defaults['delete_speedbump'] = self.rows_deletable_speedbump - defaults['main_actions'] = actions + defaults['actions'] = actions defaults.update(kwargs) return defaults @@ -630,9 +644,8 @@ 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(key, data, columns, **kwargs) + grid = factory(self.request, key=key, data=data, columns=columns, **kwargs) self.configure_version_grid(grid) grid.load_settings() return grid @@ -661,9 +674,9 @@ class MasterView(View): 'pageable': True, 'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id), } - if 'main_actions' not in kwargs: + if 'actions' not in kwargs: url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) - defaults['main_actions'] = [ + defaults['actions'] = [ self.make_action('view', icon='eye', url=url), ] defaults.update(kwargs) @@ -1372,7 +1385,7 @@ class MasterView(View): 'sortable': True, 'default_sortkey': 'changed', 'default_sortdir': 'desc', - 'main_actions': [ + 'actions': [ self.make_action('view', icon='eye', url='#', click_handler='viewRevision(props.row)'), self.make_action('view_separate', url=row_url, target='_blank', @@ -3111,6 +3124,11 @@ 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, [] diff --git a/tailbone/views/members.py b/tailbone/views/members.py index de844eb7..46ed7e4b 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -229,8 +229,7 @@ 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.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.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 163a9a52..020babc5 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -175,8 +175,7 @@ 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.main_actions.insert(1, self.make_action( - 'view_raw', url=url, icon='eye')) + g.actions.insert(1, self.make_action('view_raw', url=url, icon='eye')) g.set_link('display_name') g.set_link('first_name') @@ -522,9 +521,9 @@ class PersonView(MasterView): data = self.profile_transactions_query(person) factory = self.get_grid_factory() g = factory( - f'{route_prefix}.profile.transactions.{person.uuid}', - data, - request=self.request, + self.request, + key=f'{route_prefix}.profile.transactions.{person.uuid}', + data=data, model_class=model.Transaction, ajax_data_url=self.get_action_url('view_profile_transactions', person), columns=[ @@ -552,7 +551,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.main_actions.append(self.make_action('view', icon='eye', url=url)) + g.actions.append(self.make_action('view', icon='eye', url=url)) g.load_settings() g.set_enum('system', self.enum.TRAINWRECK_SYSTEM) @@ -1413,9 +1412,9 @@ class PersonView(MasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - '{}.profile.revisions'.format(route_prefix), - [], # start with empty data! - request=self.request, + self.request, + key=f'{route_prefix}.profile.revisions', + data=[], # start with empty data! columns=[ 'changed', 'changed_by', @@ -1430,7 +1429,7 @@ class PersonView(MasterView): 'changed_by', 'comment', ], - main_actions=[ + actions=[ self.make_action('view', icon='eye', url='#', click_handler='viewRevision(props.row)'), ], diff --git a/tailbone/views/poser/reports.py b/tailbone/views/poser/reports.py index 462df51d..ded80b18 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.more_actions.append(self.make_action( + g.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 bb799efc..3986f8b0 100644 --- a/tailbone/views/principal.py +++ b/tailbone/views/principal.py @@ -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(key=f'{route_prefix}.results', - request=self.request, + g = factory(self.request, + key=f'{route_prefix}.results', data=[], columns=[], - main_actions=[ + actions=[ self.make_action('view', icon='eye', click_handler='navigateTo(props.row._url)'), ]) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index bf2d7f14..c546a0f4 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -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.more_actions.append(self.make_action( + g.actions.append(self.make_action( 'print_label', icon='print', url='#', click_handler='quickLabelPrint(props.row)')) @@ -1197,8 +1197,9 @@ class ProductView(MasterView): # regular price data = [] # defer fetching until user asks for it - grid = grids.Grid('products.regular_price_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.regular_price_history', + data=data, columns=[ 'price', 'since', @@ -1211,8 +1212,9 @@ class ProductView(MasterView): # current price data = [] # defer fetching until user asks for it - grid = grids.Grid('products.current_price_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.current_price_history', + data=data, columns=[ 'price', 'price_type', @@ -1229,8 +1231,9 @@ class ProductView(MasterView): # suggested price data = [] # defer fetching until user asks for it - grid = grids.Grid('products.suggested_price_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.suggested_price_history', + data=data, columns=[ 'price', 'since', @@ -1243,8 +1246,9 @@ class ProductView(MasterView): # cost history data = [] # defer fetching until user asks for it - grid = grids.Grid('products.cost_history', data, - request=self.request, + grid = grids.Grid(self.request, + key='products.cost_history', + data=data, columns=[ 'cost', 'vendor', @@ -1335,7 +1339,8 @@ class ProductView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.vendor_sources'.format(route_prefix), + self.request, + key=f'{route_prefix}.vendor_sources', data=[], columns=columns, labels={ @@ -1376,7 +1381,8 @@ class ProductView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.lookup_codes'.format(route_prefix), + self.request, + key=f'{route_prefix}.lookup_codes', data=[], columns=[ 'sequence', diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 1d11130c..590b9af5 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -793,8 +793,8 @@ class PurchasingBatchView(BatchMasterView): factory = self.get_grid_factory() g = factory( - key='{}.row_credits'.format(route_prefix), - request=self.request, + self.request, + key=f'{route_prefix}.row_credits', data=[], columns=[ 'credit_type', diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 0a305f0a..de19a2b9 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -774,8 +774,10 @@ class ReceivingBatchView(PurchasingBatchView): breakdown = self.make_po_vs_invoice_breakdown(batch) factory = self.get_grid_factory() - g = factory('batch_po_vs_invoice_breakdown', [], - columns=['title', 'count']) + g = factory(self.request, + key='batch_po_vs_invoice_breakdown', + data=[], + 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( @@ -1035,10 +1037,12 @@ class ReceivingBatchView(PurchasingBatchView): icon='shuffle', label="Transform to Unit", url=self.transform_unit_url) - 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) + 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) # truck_dump_status if not batch.is_truck_dump_parent(): @@ -1111,7 +1115,7 @@ class ReceivingBatchView(PurchasingBatchView): and self.row_editable(row)): # add the Un-Declare action - g.main_actions.append(self.make_action( + g.actions.append(self.make_action( 'remove', label="Un-Declare", url='#', icon='trash', link_class='has-text-danger', diff --git a/tailbone/views/reports.py b/tailbone/views/reports.py index aedda61c..099224be 100644 --- a/tailbone/views/reports.py +++ b/tailbone/views/reports.py @@ -308,7 +308,8 @@ class ReportOutputView(ExportMasterView): route_prefix = self.get_route_prefix() factory = self.get_grid_factory() g = factory( - key='{}.params'.format(route_prefix), + self.request, + key=f'{route_prefix}.params', data=params, columns=['key', 'value'], labels={'key': "Name"}, @@ -705,9 +706,12 @@ class ProblemReportView(MasterView): return ', '.join(recips) def render_days(self, report_info, field): - g = self.get_grid_factory()('days', [], - columns=['weekday_name', 'enabled'], - labels={'weekday_name': "Weekday"}) + factory = self.get_grid_factory() + g = factory(self.request, + key='days', + data=[], + 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 fb834479..e8a6d8a2 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -255,8 +255,8 @@ class RoleView(PrincipalMasterView): permission_prefix = self.get_permission_prefix() factory = self.get_grid_factory() g = factory( - key='{}.users'.format(route_prefix), - request=self.request, + self.request, + key=f'{route_prefix}.users', data=[], columns=[ 'full_name', @@ -269,9 +269,9 @@ class RoleView(PrincipalMasterView): ) if self.request.has_perm('users.view'): - g.main_actions.append(self.make_action('view', icon='eye')) + g.actions.append(self.make_action('view', icon='eye')) if self.request.has_perm('users.edit'): - g.main_actions.append(self.make_action('edit', icon='edit')) + g.actions.append(self.make_action('edit', icon='edit')) return HTML.literal( g.render_table_element(data_prop='usersData')) @@ -366,10 +366,11 @@ class RoleView(PrincipalMasterView): self.make_action('view', icon='zoomin', url=lambda r, i: self.request.route_url('users.view', uuid=r.uuid)) ] - kwargs['users'] = grids.Grid(None, users, ['username', 'active'], - request=self.request, + kwargs['users'] = grids.Grid(self.request, + data=users, + columns=['username', 'active'], model_class=model.User, - main_actions=actions) + actions=actions) else: kwargs['users'] = None diff --git a/tailbone/views/tempmon/core.py b/tailbone/views/tempmon/core.py index d551d6e6..7540abbe 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( - key='{}.probes'.format(route_prefix), - request=self.request, + self.request, + key=f'{route_prefix}.probes', data=[], columns=[ 'description', @@ -96,7 +96,7 @@ class MasterView(views.MasterView): 'critical_temp_max': "Crit. Max", }, linked_columns=['description'], - main_actions=actions, + 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 9c150c6a..d5f077aa 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( - key='{}.custorder_xref_markers'.format(route_prefix), + self.request, + key=f'{route_prefix}.custorder_xref_markers', data=[], - columns=['custorder_xref', 'custorder_item_xref'], - request=self.request) + columns=['custorder_xref', 'custorder_item_xref']) return HTML.literal( g.render_table_element(data_prop='custorderXrefMarkersData')) @@ -355,11 +355,11 @@ class TransactionView(MasterView): factory = self.get_grid_factory() g = factory( - key='{}.discounts'.format(route_prefix), + self.request, + key=f'{route_prefix}.discounts', data=[], columns=['discount_type', 'description', 'amount'], - labels={'discount_type': "Type"}, - request=self.request) + labels={'discount_type': "Type"}) return HTML.literal( g.render_table_element(data_prop='discountsData')) diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 9eae74d8..9b533efe 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -44,9 +44,6 @@ 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 @@ -77,6 +74,11 @@ class UserView(PrincipalMasterView): 'permissions', ] + has_rows = True + model_row_class = UserEvent + rows_title = "User Events" + rows_viewable = False + row_grid_columns = [ 'type_code', 'occurred', @@ -297,11 +299,11 @@ class UserView(PrincipalMasterView): factory = self.get_grid_factory() g = factory( - request=self.request, - key='{}.api_tokens'.format(route_prefix), + self.request, + key=f'{route_prefix}.api_tokens', data=[], columns=['description', 'created'], - main_actions=[ + actions=[ self.make_action('delete', icon='trash', click_handler="$emit('api-token-delete', props.row)")]) @@ -514,7 +516,6 @@ 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 diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 0a8d5d66..0d0fe112 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -12,9 +12,8 @@ class TestGrid(WebTestCase): self.setup_web() self.config.setdefault('rattail.web.menus.handler_spec', 'tests.util:NullMenuHandler') - def make_grid(self, key, data=[], **kwargs): - kwargs.setdefault('request', self.request) - return mod.Grid(key, data=data, **kwargs) + 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') @@ -90,6 +89,50 @@ class TestGrid(WebTestCase): 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) + 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_render_vue_tag(self): model = self.app.model From 9da2a148c65ebde63b39903028bdf77577d53780 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Aug 2024 18:45:04 -0500 Subject: [PATCH 114/211] feat: move "basic" grid pagination logic to wuttaweb so far only "simple" pagination is supported by wuttaweb, so basically the main feature flag, page size, current page. in this scenario *all* data is written to client-side JSON and Buefy handles the actual pagination. backend pagination coming soon for wuttaweb but for now tailbone still handles all that. --- tailbone/grids/core.py | 130 +++++++++++++++++-------- tailbone/templates/grids/complete.mako | 18 ++-- tailbone/views/master.py | 4 +- tailbone/views/wutta/people.py | 4 + tests/grids/test_core.py | 86 ++++++++++++++++ 5 files changed, 195 insertions(+), 47 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index a5617215..0b23fb78 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -31,6 +31,7 @@ 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 @@ -209,9 +210,6 @@ class Grid(WuttaGrid): sorters={}, default_sortkey=None, default_sortdir='asc', - pageable=False, - default_pagesize=None, - default_page=1, checkboxes=False, checked=None, check_handler=None, @@ -233,7 +231,26 @@ class Grid(WuttaGrid): DeprecationWarning, stacklevel=2) kwargs.setdefault('vue_tagname', kwargs.pop('component')) - # TODO: pretty sure this should go away? + if kwargs.get('pageable'): + warnings.warn("component param is deprecated for Grid(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('paginated', kwargs.pop('pageable')) + + if kwargs.get('default_pagesize'): + 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 kwargs.get('default_page'): + warnings.warn("default_page param is deprecated for Grid(); " + "please use page param instead", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('page', kwargs.pop('default_page')) + + # TODO: this should not be needed once all templates correctly + # reference grid.vue_component etc. kwargs.setdefault('vue_tagname', 'tailbone-grid') kwargs['key'] = key @@ -272,10 +289,6 @@ class Grid(WuttaGrid): 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 if self.checked is None: @@ -333,6 +346,16 @@ class Grid(WuttaGrid): DeprecationWarning, stacklevel=2) return self.vue_component + def get_pageable(self): + """ """ + return self.paginated + + def set_pageable(self, value): + """ """ + self.paginated = value + + pageable = property(get_pageable, set_pageable) + def hide_column(self, key): """ This *removes* a column from the grid, altogether. @@ -756,18 +779,61 @@ class Grid(WuttaGrid): keyfunc = lambda v: v[key] return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc') - def get_default_pagesize(self): + 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) + if self.default_pagesize: return self.default_pagesize - pagesize = self.request.rattail_config.getint('tailbone', - 'grid.default_pagesize', - default=0) - if pagesize: - return pagesize - - options = self.get_pagesize_options() - return options[0] + return self.get_pagesize() def load_settings(self, store=True): """ @@ -789,9 +855,9 @@ class Grid(WuttaGrid): settings['sorters.1.dir'] = self.default_sortdir else: settings['sorters.length'] = 0 - if self.pageable: - settings['pagesize'] = self.get_default_pagesize() - settings['page'] = self.default_page + if self.paginated: + settings['pagesize'] = self.pagesize + settings['page'] = self.page if self.filterable: for filtr in self.iter_filters(): settings['filter.{}.active'.format(filtr.key)] = filtr.default_active @@ -867,7 +933,7 @@ class Grid(WuttaGrid): 'field': settings[f'sorters.{i}.key'], 'order': settings[f'sorters.{i}.dir'], }) - if self.pageable: + if self.paginated: self.pagesize = settings['pagesize'] self.page = settings['page'] @@ -971,7 +1037,7 @@ class Grid(WuttaGrid): merge(f'sorters.{i}.key') merge(f'sorters.{i}.dir') - if self.pageable: + if self.paginated: merge('pagesize', int) merge('page', int) @@ -1154,7 +1220,7 @@ class Grid(WuttaGrid): :param settings: Dictionary of initial settings, which is to be updated. """ - if not self.pageable: + if not self.paginated: return pagesize = self.request.GET.get('pagesize') @@ -1231,7 +1297,7 @@ class Grid(WuttaGrid): persist(f'sorters.{i}.key') persist(f'sorters.{i}.dir') - if self.pageable: + if self.paginated: persist('pagesize') persist('page') @@ -1355,7 +1421,7 @@ class Grid(WuttaGrid): data = self.filter_data(data) if self.sortable: data = self.sort_data(data) - if self.pageable: + if self.paginated: self.pager = self.paginate_data(data) data = self.pager return data @@ -1580,18 +1646,6 @@ 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" @@ -1734,7 +1788,7 @@ class Grid(WuttaGrid): results['checked_rows_code'] = '[{}]'.format( ', '.join(['{}[{}]'.format(var, i) for i in checked])) - if self.pageable and self.pager is not None: + if self.paginated 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 diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 93bb6c26..53043803 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -107,12 +107,14 @@ @cellclick="cellClick" % endif + % if grid.paginated: :paginated="paginated" :per-page="perPage" :current-page="currentPage" backend-pagination :total="total" @page-change="onPageChange" + % endif ## TODO: should let grid (or master view) decide how to set these? icon-pack="fas" @@ -203,7 +205,7 @@ <div></div> % endif - % if getattr(grid, 'pageable', False): + % if grid.paginated: <div v-if="firstItem" style="display: flex; gap: 0.5rem; align-items: center;"> <span> @@ -255,12 +257,14 @@ checkedRows: ${grid_data['checked_rows_code']|n}, % endif - paginated: ${json.dumps(getattr(grid, 'pageable', False))|n}, + % if grid.paginated: + paginated: ${json.dumps(grid.paginated)|n}, total: ${len(grid_data['data']) if static_data else (grid_data['total_items'] if grid_data is not Undefined else 0)}, - perPage: ${json.dumps(grid.pagesize if getattr(grid, 'pageable', False) else None)|n}, - currentPage: ${json.dumps(grid.page if getattr(grid, 'pageable', False) else None)|n}, - firstItem: ${json.dumps(grid_data['first_item'] if getattr(grid, 'pageable', False) else None)|n}, - lastItem: ${json.dumps(grid_data['last_item'] if getattr(grid, 'pageable', False) else None)|n}, + perPage: ${json.dumps(grid.pagesize if grid.paginated else None)|n}, + currentPage: ${json.dumps(grid.page if grid.paginated else None)|n}, + firstItem: ${json.dumps(grid_data['first_item'] if grid.paginated else None)|n}, + lastItem: ${json.dumps(grid_data['last_item'] if grid.paginated else None)|n}, + % endif % if getattr(grid, 'sortable', False): @@ -439,7 +443,7 @@ params['sort'+i+'dir'] = this.backendSorters[i-1].order } % endif - % if getattr(grid, 'pageable', False): + % if grid.paginated: params.pagesize = this.perPage params.page = this.currentPage % endif diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 8f65fc88..58b93568 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -439,7 +439,7 @@ class MasterView(View): 'filterable': self.filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.sortable, - 'pageable': self.pageable, + 'paginated': self.pageable, 'extra_row_class': self.grid_extra_class, 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': checkboxes, @@ -589,7 +589,7 @@ class MasterView(View): } if self.rows_default_pagesize: - defaults['default_pagesize'] = self.rows_default_pagesize + defaults['pagesize'] = self.rows_default_pagesize if self.has_rows and 'actions' not in defaults: actions = [] diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index c92e34ae..3158b478 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -45,6 +45,10 @@ class PersonView(wutta.PersonView): model_class = Person Session = Session + # TODO: /grids/complete.mako is too aggressive for the + # limited support we have in wuttaweb thus far + paginated = False + labels = { 'display_name': "Full Name", } diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 0d0fe112..7cba917a 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -19,6 +19,32 @@ class TestGrid(WebTestCase): 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') + + # 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) + def test_vue_tagname(self): # default @@ -133,6 +159,66 @@ class TestGrid(WebTestCase): grid.set_action_urls(setting, setting, 0) self.assertEqual(setting['_action_url_view'], '/blarg') + 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_render_vue_tag(self): model = self.app.model From f4c8176d8325f052e4aa46666b6ae9d5a5779e75 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 16 Aug 2024 22:54:22 -0500 Subject: [PATCH 115/211] =?UTF-8?q?bump:=20version=200.17.0=20=E2=86=92=20?= =?UTF-8?q?0.18.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5724e685..0671e03b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index 31c7ef8d..bd4882c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.17.0" +version = "0.18.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.8.1", + "WuttaWeb>=0.9.0", "zope.sqlalchemy>=1.5", ] From 5e82fe3946d4a65c67527b704198ac5a8d73c6e1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Aug 2024 10:20:09 -0500 Subject: [PATCH 116/211] fix: fix broken permission directives in web api startup --- tailbone/webapi.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tailbone/webapi.py b/tailbone/webapi.py index 7c0e9b41..d0edb412 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -85,8 +85,15 @@ def make_pyramid_config(settings): provider.configure_db_sessions(rattail_config, pyramid_config) # add some permissions magic - pyramid_config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - pyramid_config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + 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') return pyramid_config From c95e42bf828b93f22247660e15df67f2c431a5c4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 17 Aug 2024 11:05:15 -0500 Subject: [PATCH 117/211] fix: fix misc. errors in grid template per wuttaweb --- tailbone/templates/grids/complete.mako | 91 +++++++++++++++++++------- tailbone/views/master.py | 5 +- tailbone/views/wutta/people.py | 10 --- 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 53043803..d3981a16 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -107,13 +107,17 @@ @cellclick="cellClick" % endif + ## paging % if grid.paginated: - :paginated="paginated" - :per-page="perPage" - :current-page="currentPage" - backend-pagination - :total="total" - @page-change="onPageChange" + paginated + pagination-size="is-small" + :per-page="perPage" + :current-page="currentPage" + @page-change="onPageChange" + % if grid.paginate_on_backend: + backend-pagination + :total="pagerStats.item_count" + % endif % endif ## TODO: should let grid (or master view) decide how to set these? @@ -206,12 +210,13 @@ % endif % if grid.paginated: - <div v-if="firstItem" + <div v-if="pagerStats.first_item" style="display: flex; gap: 0.5rem; align-items: center;"> <span> showing - {{ firstItem.toLocaleString('en') }} - {{ lastItem.toLocaleString('en') }} - of {{ total.toLocaleString('en') }} results; + {{ renderNumber(pagerStats.first_item) }} + - {{ renderNumber(pagerStats.last_item) }} + of {{ renderNumber(pagerStats.item_count) }} results; </span> <b-select v-model="perPage" size="is-small" @@ -257,13 +262,14 @@ checkedRows: ${grid_data['checked_rows_code']|n}, % endif + ## paging % if grid.paginated: - paginated: ${json.dumps(grid.paginated)|n}, - total: ${len(grid_data['data']) if static_data else (grid_data['total_items'] if grid_data is not Undefined else 0)}, - perPage: ${json.dumps(grid.pagesize if grid.paginated else None)|n}, - currentPage: ${json.dumps(grid.page if grid.paginated else None)|n}, - firstItem: ${json.dumps(grid_data['first_item'] if grid.paginated else None)|n}, - lastItem: ${json.dumps(grid_data['last_item'] if grid.paginated else None)|n}, + 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 % if getattr(grid, 'sortable', False): @@ -311,6 +317,32 @@ 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 @@ -373,6 +405,12 @@ methods: { + renderNumber(value) { + if (value != undefined) { + return value.toLocaleString('en') + } + }, + formatAddFilterItem(filtr) { if (!filtr.key) { filtr = this.filters[filtr] @@ -486,23 +524,23 @@ params = params.toString() this.loading = true - this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(({ data }) => { - if (!data.error) { - ${grid.vue_component}CurrentData = data.data + this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => { + if (!response.data.error) { + ${grid.vue_component}CurrentData = response.data.data.data this.data = ${grid.vue_component}CurrentData - this.rowStatusMap = data.row_status_map - this.total = data.total_items - this.firstItem = data.first_item - this.lastItem = data.last_item + % if grid.paginated and grid.paginate_on_backend: + this.pagerStats = response.data.pager_stats + % endif + this.rowStatusMap = response.data.data.row_status_map this.loading = false this.savingDefaults = false - this.checkedRows = this.locateCheckedRows(data.checked_rows) + this.checkedRows = this.locateCheckedRows(response.data.data.checked_rows) if (success) { success() } } else { this.$buefy.toast.open({ - message: data.error, + message: response.data.error, type: 'is-danger', duration: 2000, // 4 seconds }) @@ -514,8 +552,11 @@ } }) .catch((error) => { + ${grid.vue_component}CurrentData = [] this.data = [] - this.total = 0 + % if grid.paginated and grid.paginate_on_backend: + this.pagerStats = {} + % endif this.loading = false this.savingDefaults = false if (failure) { diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 58b93568..1fa0ae40 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -346,7 +346,10 @@ class MasterView(View): # return grid data only, if partial page was requested if self.request.params.get('partial'): - return self.json_response(grid.get_table_data()) + context = {'data': grid.get_table_data()} + if grid.paginated and grid.paginate_on_backend: + context['pager_stats'] = grid.get_vue_pager_stats() + return self.json_response(context) context = { 'grid': grid, diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index 3158b478..c10020ea 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -45,10 +45,6 @@ class PersonView(wutta.PersonView): model_class = Person Session = Session - # TODO: /grids/complete.mako is too aggressive for the - # limited support we have in wuttaweb thus far - paginated = False - labels = { 'display_name': "Full Name", } @@ -91,12 +87,6 @@ class PersonView(wutta.PersonView): # display_name g.set_link('display_name') - # first_name - g.set_link('first_name') - - # last_name - g.set_link('last_name') - # merge_requested g.set_label('merge_requested', "MR") g.set_renderer('merge_requested', self.render_merge_requested) From ec36df4a341a1e8c7ba5821fa270fdb1125b1848 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Aug 2024 14:05:52 -0500 Subject: [PATCH 118/211] feat: move single-column grid sorting logic to wuttaweb --- tailbone/forms/core.py | 26 +-- tailbone/grids/core.py | 272 ++++++++++++++----------- tailbone/templates/grids/complete.mako | 63 +++--- tailbone/views/master.py | 28 +-- tailbone/views/wutta/people.py | 8 +- tests/grids/test_core.py | 245 +++++++++++++++++++++- tests/views/test_master.py | 33 ++- 7 files changed, 475 insertions(+), 200 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index eeae4537..704d3b54 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -47,7 +47,7 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from wuttaweb.util import get_form_data, make_json_safe +from wuttaweb.util import FieldList, get_form_data, make_json_safe from tailbone.db import Session from tailbone.util import raw_datetime, render_markdown @@ -1418,30 +1418,6 @@ 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 0b23fb78..cc1888fb 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -39,7 +39,8 @@ 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 +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 @@ -48,23 +49,17 @@ from tailbone.util import raw_datetime log = logging.getLogger(__name__) -class FieldList(list): - """ - Convenience wrapper for a field list. - """ - - def insert_before(self, field, newfield): - i = self.index(field) - self.insert(i, newfield) - - def insert_after(self, field, newfield): - i = self.index(field) - self.insert(i + 1, newfield) - - class Grid(WuttaGrid): """ - Core grid class. In sore need of documentation. + 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. + + 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. .. _Buefy docs: https://buefy.org/documentation/table/ @@ -206,10 +201,6 @@ class Grid(WuttaGrid): filters={}, use_byte_string_filters=False, searchable={}, - sortable=False, - sorters={}, - default_sortkey=None, - default_sortdir='asc', checkboxes=False, checked=None, check_handler=None, @@ -231,6 +222,20 @@ class Grid(WuttaGrid): DeprecationWarning, stacklevel=2) kwargs.setdefault('vue_tagname', kwargs.pop('component')) + if kwargs.get('default_sortkey'): + warnings.warn("default_sortkey param is deprecated for Grid(); " + "please use sort_defaults param instead", + DeprecationWarning, stacklevel=2) + if kwargs.get('default_sortdir'): + warnings.warn("default_sortdir param is deprecated for Grid(); " + "please use sort_defaults param instead", + DeprecationWarning, stacklevel=2) + if kwargs.get('default_sortkey') or kwargs.get('default_sortdir'): + sortkey = kwargs.pop('default_sortkey', None) + sortdir = kwargs.pop('default_sortdir', 'asc') + if sortkey: + kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) + if kwargs.get('pageable'): warnings.warn("component param is deprecated for Grid(); " "please use vue_tagname param instead", @@ -284,11 +289,6 @@ class Grid(WuttaGrid): self.searchable = searchable or {} - self.sortable = sortable - self.sorters = self.make_sorters(sorters) - self.default_sortkey = default_sortkey - self.default_sortdir = default_sortdir - self.checkboxes = checkboxes self.checked = checked if self.checked is None: @@ -328,9 +328,7 @@ class Grid(WuttaGrid): @property def component(self): - """ - DEPRECATED - use :attr:`vue_tagname` instead. - """ + """ """ warnings.warn("Grid.component is deprecated; " "please use vue_tagname instead", DeprecationWarning, stacklevel=2) @@ -338,20 +336,66 @@ class Grid(WuttaGrid): @property def component_studly(self): - """ - DEPRECATED - use :attr:`vue_component` instead. - """ + """ """ warnings.warn("Grid.component_studly is deprecated; " "please use vue_component instead", DeprecationWarning, stacklevel=2) return self.vue_component + 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 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')] + + 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) @@ -405,18 +449,30 @@ class Grid(WuttaGrid): self.joiners[key] = joiner def set_sorter(self, key, *args, **kwargs): - if len(args) == 1 and args[0] is None: - self.remove_sorter(key) + """ """ + + 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) + 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 remove_sorter(self, key): - self.sorters.pop(key, None) - - 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) @@ -731,53 +787,12 @@ 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): - """ - 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') + """ """ + 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): """ """ @@ -849,10 +864,17 @@ class Grid(WuttaGrid): # initial default settings settings = {} if self.sortable: - if self.default_sortkey: + if self.sort_defaults: + sort_defaults = self.sort_defaults + if len(sort_defaults) > 1: + log.warning("multiple sort defaults are not yet supported; " + "list will be pruned to first element for '%s' grid: %s", + self.key, sort_defaults) + sort_defaults = [sort_defaults[0]] + sortinfo = sort_defaults[0] settings['sorters.length'] = 1 - settings['sorters.1.key'] = self.default_sortkey - settings['sorters.1.dir'] = self.default_sortdir + settings['sorters.1.key'] = sortinfo.sortkey + settings['sorters.1.dir'] = sortinfo.sortdir else: settings['sorters.length'] = 0 if self.paginated: @@ -927,11 +949,12 @@ 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({ - 'field': settings[f'sorters.{i}.key'], - 'order': settings[f'sorters.{i}.dir'], + 'key': settings[f'sorters.{i}.key'], + 'dir': settings[f'sorters.{i}.dir'], }) if self.paginated: self.pagesize = settings['pagesize'] @@ -1321,21 +1344,24 @@ 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: + def sort_data(self, data, sorters=None): + """ """ + if sorters is None: + sorters = self.active_sorters + if not sorters: return data - # TODO: is there a better way to check for SA sorting? - if self.model_class: + # sqlalchemy queries require special handling, in case of + # multi-column sorting + if isinstance(data, orm.Query): # collect actual column sorters for order_by clause - sorters = [] - for sorter in self.active_sorters: - sortkey = sorter['field'] + query_sorters = [] + for sorter in sorters: + sortkey = sorter['key'] + sortdir = sorter['dir'] + + # cannot sort unless we have a sorter callable sortfunc = self.sorters.get(sortkey) if not sortfunc: log.warning("unknown sorter: %s", sorter) @@ -1347,34 +1373,36 @@ class Grid(WuttaGrid): self.joined.add(sortkey) # add column/dir to collection - sortdir = sorter['order'] - sorters.append(getattr(sortfunc._column, sortdir)()) + query_sorters.append(getattr(sortfunc._column, sortdir)()) # apply sorting to query - if sorters: - data = data.order_by(*sorters) + if query_sorters: + data = data.order_by(*query_sorters) return data - else: - # not a SQLAlchemy grid, custom sorter + # manual sorting; only one column allowed + if len(sorters) != 1: + raise NotImplementedError("mulit-column manual sorting not yet supported") - assert len(self.active_sorters) < 2 + # our one and only active sorter + sorter = sorters[0] + sortkey = sorter['key'] + sortdir = sorter['dir'] - sortkey = self.active_sorters[0]['field'] - sortdir = self.active_sorters[0]['order'] or 'asc' + # cannot sort unless we have a sorter callable + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + return data - # Cannot sort unless we have a sort function. - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - return data + # apply joins needed for this sorter + # TODO: is this actually relevant for manual sort? + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) - # 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) + # invoke the sorter + return sortfunc(data, sortdir) def paginate_data(self, data): """ @@ -1671,7 +1699,7 @@ class Grid(WuttaGrid): columns.append({ 'field': name, 'label': self.get_label(name), - 'sortable': self.sortable and name in self.sorters, + 'sortable': self.is_sortable(name), 'visible': name not in self.invisible, }) return columns diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index d3981a16..5a005c2e 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -81,7 +81,11 @@ % endif % endif - % if getattr(grid, 'sortable', False): + ## sorting + % if grid.sortable: + ## nb. buefy only supports *one* default sorter + :default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null" + backend-sorting @sort="onSort" @sorting-priority-removed="sortingPriorityRemoved" @@ -93,8 +97,6 @@ ## 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" @@ -272,7 +274,9 @@ % endif % endif - % if getattr(grid, 'sortable', False): + ## sorting + % if grid.sortable: + sorters: ${json.dumps(grid.active_sorters)|n}, ## TODO: there is a bug (?) which prevents the arrow from ## displaying for simple default single-column sort. so to @@ -281,10 +285,7 @@ ## 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, + ## nb. 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}, @@ -474,17 +475,18 @@ }, getBasicParams() { - let params = {} - % if getattr(grid, 'sortable', False): - 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 + 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].key + params['sort'+i+'dir'] = this.sorters[i-1].dir } % endif - % if grid.paginated: - params.pagesize = this.perPage - params.page = this.currentPage - % endif return params }, @@ -526,15 +528,15 @@ this.loading = true this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => { if (!response.data.error) { - ${grid.vue_component}CurrentData = response.data.data.data + ${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.data.row_status_map + this.rowStatusMap = response.data.row_status_map || {} this.loading = false this.savingDefaults = false - this.checkedRows = this.locateCheckedRows(response.data.data.checked_rows) + this.checkedRows = this.locateCheckedRows(response.data.checked_rows || []) if (success) { success() } @@ -597,26 +599,26 @@ onSort(field, order, event) { - // nb. buefy passes field name, oruga passes object - if (field.field) { + ## nb. buefy passes field name; oruga passes field object + % if request.use_oruga: field = field.field - } + % endif if (event.ctrlKey) { // engage or enhance multi-column sorting - let sorter = this.backendSorters.filter(i => i.field === field)[0] + const sorter = this.sorters.filter(s => s.key === field)[0] if (sorter) { - sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' + sorter.dir = sorter.dir === 'desc' ? 'asc' : 'desc' } else { - this.backendSorters.push({field, order}) + this.sorters.push({key: field, dir: order}) } - this.sortingPriority = this.backendSorters + this.sortingPriority = this.sorters } else { // sort by single column only - this.backendSorters = [{field, order}] + this.sorters = [{key: field, dir: order}] this.sortingPriority = [] } @@ -629,12 +631,11 @@ sortingPriorityRemoved(field) { // prune field from active sorters - this.backendSorters = this.backendSorters.filter( - (sorter) => sorter.field !== field) + this.sorters = this.sorters.filter(s => s.key !== 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 + this.sortingPriority = this.sorters this.loadAsyncData() }, diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1fa0ae40..53f46020 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -345,8 +345,8 @@ class MasterView(View): self.first_visible_grid_index = grid.pager.first_item # return grid data only, if partial page was requested - if self.request.params.get('partial'): - context = {'data': grid.get_table_data()} + if self.request.GET.get('partial'): + context = grid.get_table_data() if grid.paginated and grid.paginate_on_backend: context['pager_stats'] = grid.get_vue_pager_stats() return self.json_response(context) @@ -2565,11 +2565,12 @@ 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.model route_prefix = self.get_route_prefix() - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailbonePageHelp)\ + info = session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if info and info.help_url: @@ -2587,11 +2588,12 @@ 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.model route_prefix = self.get_route_prefix() - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailbonePageHelp)\ + info = session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if info and info.markdown_text: @@ -2608,6 +2610,8 @@ 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.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2625,13 +2629,12 @@ class MasterView(View): if not form.validate(): return {'error': "Form did not validate"} - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailbonePageHelp)\ + 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'] @@ -2641,6 +2644,8 @@ 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.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2657,15 +2662,14 @@ class MasterView(View): if not form.validate(): return {'error': "Form did not validate"} - # nb. self.Session may differ, so use tailbone.db.Session - info = Session.query(model.TailboneFieldInfo)\ + 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} diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index c10020ea..968eaf3d 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -44,6 +44,7 @@ class PersonView(wutta.PersonView): """ model_class = Person Session = Session + sort_defaults = 'display_name' labels = { 'display_name': "Full Name", @@ -73,13 +74,6 @@ class PersonView(wutta.PersonView): # CRUD methods ############################## - def get_query(self, session=None): - """ """ - model = self.app.model - session = session or self.Session() - return session.query(model.Person)\ - .order_by(model.Person.display_name) - def configure_grid(self, g): """ """ super().configure_grid(g) diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 7cba917a..9f9b816f 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -1,6 +1,8 @@ # -*- coding: utf-8; -*- -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch + +from sqlalchemy import orm from tailbone.grids import core as mod from tests.util import WebTestCase @@ -27,6 +29,16 @@ class TestGrid(WebTestCase): 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) @@ -159,6 +171,27 @@ class TestGrid(WebTestCase): 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) @@ -219,6 +252,212 @@ class TestGrid(WebTestCase): 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_sort_data(self): + model = self.app.model + sample_data = [ + {'name': 'foo1', 'value': 'ONE'}, + {'name': 'foo2', 'value': 'two'}, + {'name': 'foo3', 'value': 'three'}, + {'name': 'foo4', 'value': 'four'}, + {'name': 'foo5', 'value': 'five'}, + {'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') + + # error if mult-column sort attempted + self.assertRaises(NotImplementedError, grid.sort_data, sample_data, sorters=[ + {'key': 'name', 'dir': 'desc'}, + {'key': 'value', 'dir': 'asc'}, + ]) + + # cannot sort data if sortfunc is missing for column + grid.remove_sorter('name') + sorted_data = grid.sort_data(sample_data) + # 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') + + # cannot sort data if sortfunc is missing for column + grid.remove_sorter('name') + # nb. attempting multi-column sort, but only one sorter exists + self.assertEqual(list(grid.sorters), ['value']) + grid.active_sorters = [{'key': 'name', 'dir': 'asc'}, + {'key': 'value', 'dir': 'asc'}] + with patch.object(sample_query, 'order_by') as order_by: + order_by.return_value = 42 + sorted_query = grid.sort_data(sample_query) + order_by.assert_called_once() + self.assertEqual(len(order_by.call_args.args), 1) + self.assertEqual(sorted_query, 42) + def test_render_vue_tag(self): model = self.app.model @@ -249,11 +488,13 @@ class TestGrid(WebTestCase): model = self.app.model # sanity check - grid = self.make_grid('settings', model_class=model.Setting) + 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 diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 572875a0..0e459e7d 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -1,6 +1,6 @@ # -*- coding: utf-8; -*- -from unittest.mock import patch +from unittest.mock import patch, MagicMock from tailbone.views import master as mod from wuttaweb.grids import GridAction @@ -33,3 +33,34 @@ class TestMasterView(WebTestCase): 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') From 290f8fd51eddca9e2f3778a23f44bfe356e94ad7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Aug 2024 19:22:04 -0500 Subject: [PATCH 119/211] feat: move multi-column grid sorting logic to wuttaweb tailbone grid template still duplicates much for Vue, and will until we can port the filters and anything else remaining.. --- tailbone/grids/core.py | 251 +++++++------------------ tailbone/templates/base.mako | 18 +- tailbone/templates/grids/complete.mako | 181 ++++++++++-------- tailbone/views/master.py | 3 +- tests/grids/test_core.py | 91 ++++++--- 5 files changed, 252 insertions(+), 292 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index cc1888fb..9c445fec 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -850,28 +850,23 @@ class Grid(WuttaGrid): return self.get_pagesize() - 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. - """ + 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')) + + persist = kwargs.get('persist', True) # initial default settings settings = {} if self.sortable: if self.sort_defaults: - sort_defaults = self.sort_defaults - if len(sort_defaults) > 1: - log.warning("multiple sort defaults are not yet supported; " - "list will be pruned to first element for '%s' grid: %s", - self.key, sort_defaults) - sort_defaults = [sort_defaults[0]] - sortinfo = sort_defaults[0] + # nb. as of writing neither Buefy nor Oruga support a + # multi-column *default* sort; so just use first sorter + sortinfo = self.sort_defaults[0] settings['sorters.length'] = 1 settings['sorters.1.key'] = sortinfo.sortkey settings['sorters.1.dir'] = sortinfo.sortdir @@ -900,16 +895,16 @@ class Grid(WuttaGrid): 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, 'request') + self.update_sort_settings(settings, src='request') else: - self.update_sort_settings(settings, 'session') + self.update_sort_settings(settings, src='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, 'request') + self.update_sort_settings(settings, src='request') self.update_filter_settings(settings, 'session') self.update_page_settings(settings) @@ -921,26 +916,26 @@ class Grid(WuttaGrid): elif self.request_has_settings('page'): self.update_page_settings(settings) self.update_filter_settings(settings, 'session') - self.update_sort_settings(settings, 'session') + self.update_sort_settings(settings, src='session') # If request has no settings, grab all from session. elif self.session_has_settings(): self.update_filter_settings(settings, 'session') - self.update_sort_settings(settings, 'session') + self.update_sort_settings(settings, src='session') self.update_page_settings(settings) # If no settings were found in request or session, don't store result. else: - store = False + persist = False # Maybe store settings for next time. - if store: - self.persist_settings(settings, 'session') + if persist: + self.persist_settings(settings, dest='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, 'defaults') + self.persist_settings(settings, dest='defaults') # update ourself to reflect settings if self.filterable: @@ -1107,44 +1102,6 @@ class Grid(WuttaGrid): return any([key.startswith(f'{prefix}.filter') for key in self.request.session]) - 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 @@ -1165,71 +1122,18 @@ class Grid(WuttaGrid): # 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, f'{filtr.key}.verb', src='request', default='') settings['{}.value'.format(prefix)] = self.get_setting( - source, settings, filtr.key, default='') + settings, filtr.key, src='request', default='') else: # source = session settings['{}.active'.format(prefix)] = self.get_setting( - source, settings, '{}.active'.format(prefix), + settings, f'{prefix}.active', src='session', normalize=lambda v: str(v).lower() == 'true', default=False) settings['{}.verb'.format(prefix)] = self.get_setting( - source, settings, '{}.verb'.format(prefix), default='') + settings, f'{prefix}.verb', src='session', 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) + settings, f'{prefix}.value', src='session', default='') def update_page_settings(self, settings): """ @@ -1264,18 +1168,19 @@ class Grid(WuttaGrid): 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``. - """ + def persist_settings(self, settings, dest='session'): + """ """ + if dest not in ('defaults', 'session'): + raise ValueError(f"invalid dest identifier: {dest}") + app = self.request.rattail_config.get_app() model = app.model - def persist(key, value=lambda k: settings[k]): - if to == 'defaults': + def persist(key, value=lambda k: settings.get(k)): + if dest == 'defaults': skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) app.save_setting(Session(), skey, value(key)) - else: # to == session + else: # dest == session skey = 'grid.{}.{}'.format(self.key, key) self.request.session[skey] = value(key) @@ -1287,9 +1192,11 @@ class Grid(WuttaGrid): if self.sortable: - # first clear existing settings for *sorting* only - # nb. this is because number of sort settings will vary - if to == 'defaults': + # 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': prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' query = Session.query(model.Setting)\ .filter(sa.or_( @@ -1303,7 +1210,9 @@ 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.'): @@ -1315,10 +1224,12 @@ class Grid(WuttaGrid): self.request.session.pop(f'{prefix}.sortkey', None) self.request.session.pop(f'{prefix}.sortdir', None) - persist('sorters.length') - for i in range(1, settings['sorters.length'] + 1): - persist(f'sorters.{i}.key') - persist(f'sorters.{i}.dir') + # 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') if self.paginated: persist('pagesize') @@ -1351,58 +1262,32 @@ class Grid(WuttaGrid): if not sorters: return data - # sqlalchemy queries require special handling, in case of - # multi-column sorting - if isinstance(data, orm.Query): + # nb. when data is a query, we want to apply sorters in the + # requested order, so the final query has order_by() in the + # correct "as-is" sequence. however when data is a list we + # must do the opposite, applying in the reverse order, so the + # final list has the most "important" sort(s) applied last. + if not isinstance(data, orm.Query): + sorters = reversed(sorters) - # collect actual column sorters for order_by clause - query_sorters = [] - for sorter in sorters: - sortkey = sorter['key'] - sortdir = sorter['dir'] + for sorter in sorters: + sortkey = sorter['key'] + sortdir = sorter['dir'] - # cannot sort unless we have a sorter callable - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - log.warning("unknown sorter: %s", sorter) - continue + # cannot sort unless we have a sorter callable + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + return data - # join appropriate model if needed - if sortkey in self.joiners and sortkey not in self.joined: - data = self.joiners[sortkey](data) - self.joined.add(sortkey) + # 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 - query_sorters.append(getattr(sortfunc._column, sortdir)()) + # invoke the sorter + data = sortfunc(data, sortdir) - # apply sorting to query - if query_sorters: - data = data.order_by(*query_sorters) - - return data - - # manual sorting; only one column allowed - if len(sorters) != 1: - raise NotImplementedError("mulit-column manual sorting not yet supported") - - # our one and only active sorter - sorter = sorters[0] - sortkey = sorter['key'] - sortdir = sorter['dir'] - - # cannot sort unless we have a sorter callable - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - return data - - # apply joins needed for this sorter - # TODO: is this actually relevant for manual sort? - if sortkey in self.joiners and sortkey not in self.joined: - data = self.joiners[sortkey](data) - self.joined.add(sortkey) - - # invoke the sorter - return sortfunc(data, sortdir) + return data def paginate_data(self, data): """ diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 3a12859e..8e3b7785 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -658,19 +658,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="${action_url('edit', instance)}" + <once-button tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> % endif - % if master.cloneable and master.has_perm('clone'): - <once-button tag="a" href="${action_url('clone', instance)}" + % if getattr(master, 'cloneable', False) and master.has_perm('clone'): + <once-button tag="a" href="${master.get_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="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -679,7 +679,7 @@ % else: ## viewing row % if instance_deletable and master.has_perm('delete_row'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -688,13 +688,13 @@ % endif % elif master and master.editing: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.deletable and instance_deletable and master.has_perm('delete'): - <once-button tag="a" href="${action_url('delete', instance)}" + <once-button tag="a" href="${master.get_action_url('delete', instance)}" type="is-danger" icon-left="trash" text="Delete This"> @@ -702,13 +702,13 @@ % endif % elif master and master.deleting: % if master.viewable and master.has_perm('view'): - <once-button tag="a" href="${action_url('view', instance)}" + <once-button tag="a" href="${master.get_action_url('view', instance)}" icon-left="eye" text="View This"> </once-button> % endif % if master.editable and instance_editable and master.has_perm('edit'): - <once-button tag="a" href="${action_url('edit', instance)}" + <once-button tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" text="Edit This"> </once-button> diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 5a005c2e..8dc2d6dc 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -83,26 +83,29 @@ ## sorting % if grid.sortable: - ## nb. buefy only supports *one* default sorter - :default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null" - - 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. 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" + ## nb. buefy/oruga only support *one* default sorter + :default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null" + % if grid.sort_on_backend: + backend-sorting + @sort="onSort" + % endif + % if grid.sort_multiple: + % if grid.sort_on_backend: + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + :sort-multiple="allowMultiSort" + :sort-multiple-data="sortingPriority" + @sorting-priority-removed="sortingPriorityRemoved" + % else: + sort-multiple + % endif + ## nb. user must ctrl-click column header for multi-sort + sort-multiple-key="ctrlKey" + % endif % endif % if getattr(grid, 'click_handlers', None): @@ -276,23 +279,24 @@ ## sorting % if grid.sortable: - sorters: ${json.dumps(grid.active_sorters)|n}, - - ## 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 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: [], + sorters: ${json.dumps(grid.get_vue_active_sorters())|n}, + % if grid.sort_multiple: + % if grid.sort_on_backend: + ## TODO: there is a bug (?) which prevents the arrow + ## from displaying for simple default single-column sort, + ## when multi-column sort is allowed for the table. for + ## now we work around that by waiting until mount to + ## enable the multi-column support. see also + ## https://github.com/buefy/buefy/issues/2584 + allowMultiSort: false, + ## nb. this should be empty when current sort is single-column + % if len(grid.active_sorters) > 1: + sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n}, + % else: + sortingPriority: [], + % endif + % endif % endif - % endif ## filterable: ${json.dumps(grid.filterable)|n}, @@ -395,14 +399,19 @@ }, }, - 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 - }, + % 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 methods: { @@ -483,8 +492,8 @@ } % 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].key - params['sort'+i+'dir'] = this.sorters[i-1].dir + params['sort'+i+'key'] = this.sorters[i-1].field + params['sort'+i+'dir'] = this.sorters[i-1].order } % endif return params @@ -597,48 +606,66 @@ }) }, - onSort(field, order, event) { + % if grid.sortable and grid.sort_on_backend: - ## nb. buefy passes field name; oruga passes field object - % if request.use_oruga: - field = field.field - % endif + onSort(field, order, event) { - if (event.ctrlKey) { + ## nb. buefy passes field name; oruga passes field object + % if request.use_oruga: + field = field.field + % endif - // engage or enhance multi-column sorting - const sorter = this.sorters.filter(s => s.key === field)[0] - if (sorter) { - sorter.dir = sorter.dir === 'desc' ? 'asc' : 'desc' - } else { - this.sorters.push({key: field, dir: order}) - } - this.sortingPriority = this.sorters + % if grid.sort_multiple: - } else { + // did user ctrl-click the column header? + if (event.ctrlKey) { + + // toggle direction for existing, or add new sorter + const sorter = this.sorters.filter(s => s.field === field)[0] + if (sorter) { + sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' + } else { + this.sorters.push({field, order}) + } + + // apply multi-column sorting + this.sortingPriority = this.sorters + + } else { + + % endif // sort by single column only - this.sorters = [{key: field, dir: order}] - this.sortingPriority = [] - } + this.sorters = [{field, order}] - // always reset to first page when changing sort options - // TODO: i mean..right? would we ever not want that? - this.currentPage = 1 - this.loadAsyncData() - }, + % if grid.sort_multiple: + // multi-column sort not engaged + this.sortingPriority = [] + } + % endif - sortingPriorityRemoved(field) { + // nb. always reset to first page when sorting changes + this.currentPage = 1 + this.loadAsyncData() + }, - // prune field from active sorters - this.sorters = this.sorters.filter(s => s.key !== field) + % if grid.sort_multiple: - // nb. must keep active sorter list "as-is" even if - // there is only one sorter; buefy seems to expect it - this.sortingPriority = this.sorters + sortingPriorityRemoved(field) { - this.loadAsyncData() - }, + // prune from active sorters + this.sorters = this.sorters.filter(s => s.field !== field) + + // nb. even though we might have just one sorter + // now, we are still technically in multi-sort mode + this.sortingPriority = this.sorters + + this.loadAsyncData() + }, + + % endif + + % endif resetView() { this.loading = true diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 53f46020..dde72106 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -341,7 +341,7 @@ class MasterView(View): return self.redirect(self.request.current_route_url(**kw)) # Stash some grid stats, for possible use when generating URLs. - if grid.pageable and hasattr(grid, 'pager'): + if grid.paginated and hasattr(grid, 'pager'): self.first_visible_grid_index = grid.pager.first_item # return grid data only, if partial page was requested @@ -442,6 +442,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, 'extra_row_class': self.grid_extra_class, 'url': lambda obj: self.get_action_url('view', obj), diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 9f9b816f..c621627a 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -388,14 +388,63 @@ class TestGrid(WebTestCase): 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': 'three'}, - {'name': 'foo4', 'value': 'four'}, - {'name': 'foo5', 'value': 'five'}, + {'name': 'foo3', 'value': 'ggg'}, + {'name': 'foo4', 'value': 'ggg'}, + {'name': 'foo5', 'value': 'ggg'}, {'name': 'foo6', 'value': 'six'}, {'name': 'foo7', 'value': 'seven'}, {'name': 'foo8', 'value': 'eight'}, @@ -432,32 +481,30 @@ class TestGrid(WebTestCase): self.assertEqual(sorted_data[0]['name'], 'foo1') self.assertEqual(sorted_data[-1]['name'], 'foo9') - # error if mult-column sort attempted - self.assertRaises(NotImplementedError, grid.sort_data, sample_data, sorters=[ - {'key': 'name', 'dir': 'desc'}, - {'key': 'value', 'dir': 'asc'}, - ]) + # 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) + 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') - # cannot sort data if sortfunc is missing for column - grid.remove_sorter('name') - # nb. attempting multi-column sort, but only one sorter exists - self.assertEqual(list(grid.sorters), ['value']) - grid.active_sorters = [{'key': 'name', 'dir': 'asc'}, - {'key': 'value', 'dir': 'asc'}] - with patch.object(sample_query, 'order_by') as order_by: - order_by.return_value = 42 - sorted_query = grid.sort_data(sample_query) - order_by.assert_called_once() - self.assertEqual(len(order_by.call_args.args), 1) - self.assertEqual(sorted_query, 42) - def test_render_vue_tag(self): model = self.app.model From b7955a587179e4c7819a9d0a67a60be280e9c386 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 18 Aug 2024 19:58:50 -0500 Subject: [PATCH 120/211] =?UTF-8?q?bump:=20version=200.18.0=20=E2=86=92=20?= =?UTF-8?q?0.19.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0671e03b..72798b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index bd4882c6..1840de77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.18.0" +version = "0.19.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.9.0", + "WuttaWeb>=0.10.0", "zope.sqlalchemy>=1.5", ] From 0fb3c0f3d2dde74157b77d0313756151c1373317 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 09:23:31 -0500 Subject: [PATCH 121/211] fix: fix broken user auth for web API app --- tailbone/api/auth.py | 12 +++++------ tailbone/app.py | 4 ---- tailbone/auth.py | 50 ++++++++++---------------------------------- 3 files changed, 16 insertions(+), 50 deletions(-) diff --git a/tailbone/api/auth.py b/tailbone/api/auth.py index 1b347b21..a710e30d 100644 --- a/tailbone/api/auth.py +++ b/tailbone/api/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,8 +24,6 @@ Tailbone Web API - Auth Views """ -from rattail.db.auth import set_user_password - from cornice import Service from tailbone.api import APIView, api @@ -42,11 +40,10 @@ class AuthenticationView(APIView): This will establish a server-side web session for the user if none exists. Note that this also resets the user's session timer. """ - data = {'ok': True} + data = {'ok': True, 'permissions': []} if self.request.user: data['user'] = self.get_user_info(self.request.user) - - data['permissions'] = list(self.request.tailbone_cached_permissions) + data['permissions'] = list(self.request.user_permissions) # background color may be set per-request, by some apps if hasattr(self.request, 'background_color') and self.request.background_color: @@ -176,7 +173,8 @@ class AuthenticationView(APIView): return {'error': "The current/old password you provided is incorrect"} # okay then, set new password - set_user_password(self.request.user, data['new_password']) + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, data['new_password']) return { 'ok': True, 'user': self.get_user_info(self.request.user), diff --git a/tailbone/app.py b/tailbone/app.py index 5e8e49d9..626c9206 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -25,19 +25,15 @@ 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 diff --git a/tailbone/auth.py b/tailbone/auth.py index fbe6bf2f..95bf90ba 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -27,20 +27,18 @@ Authentication & Authorization import logging import re -from rattail.util import NOTSET +from wuttjamaican.util import UNSPECIFIED -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=NOTSET): +def login_user(request, user, timeout=UNSPECIFIED): """ Perform the steps necessary to login the given user. Note that this returns a ``headers`` dict which you should pass to the redirect. @@ -49,7 +47,7 @@ def login_user(request, user, timeout=NOTSET): app = config.get_app() user.record_event(app.enum.USER_EVENT_LOGIN) headers = remember(request, user.uuid) - if timeout is NOTSET: + if timeout is UNSPECIFIED: timeout = session_timeout_for_user(config, user) log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) set_session_timeout(request, timeout) @@ -94,12 +92,12 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None -class TailboneSecurityPolicy: +class TailboneSecurityPolicy(WuttaSecurityPolicy): - def __init__(self, api_mode=False): + def __init__(self, db_session=None, api_mode=False, **kwargs): + kwargs['db_session'] = db_session or Session() + super().__init__(**kwargs) self.api_mode = api_mode - self.session_helper = SessionAuthenticationHelper() - self.identity_cache = RequestLocalCache(self.load_identity) def load_identity(self, request): config = request.registry.settings.get('rattail_config') @@ -115,7 +113,7 @@ class TailboneSecurityPolicy: if match: token = match.group(1) auth = app.get_auth_handler() - user = auth.authenticate_user_token(Session(), token) + user = auth.authenticate_user_token(self.db_session, token) if not user: @@ -126,36 +124,10 @@ class TailboneSecurityPolicy: # fetch user object from db model = app.model - user = Session.get(model.User, uuid) + user = self.db_session.get(model.User, uuid) if not user: return # this user is responsible for data changes in current request - Session().set_continuum_user(user) + self.db_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) From b642c98d4091729ef8f957abb213d70c2c2e8fb8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 09:23:55 -0500 Subject: [PATCH 122/211] =?UTF-8?q?bump:=20version=200.19.0=20=E2=86=92=20?= =?UTF-8?q?0.19.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72798b30..ce64ec60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.19.1 (2024-08-19) + +### Fix + +- fix broken user auth for web API app + ## v0.19.0 (2024-08-18) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 1840de77..fa33a2df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.19.0" +version = "0.19.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 1d56a4c0d09d857f3d9276ac010743eceb8e2eac Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 09:53:10 -0500 Subject: [PATCH 123/211] fix: replace all occurrences of `component_studly` => `vue_component` --- tailbone/grids/core.py | 2 +- tailbone/templates/batch/index.mako | 6 +++--- .../batch/inventory/desktop_form.mako | 4 ++-- tailbone/templates/batch/pos/view.mako | 2 +- .../templates/batch/vendorcatalog/create.mako | 12 +++++------ tailbone/templates/batch/view.mako | 18 ++++++++--------- tailbone/templates/customers/view.mako | 4 ++-- tailbone/templates/custorders/items/view.mako | 6 +++--- tailbone/templates/departments/view.mako | 2 +- tailbone/templates/importing/runjob.mako | 14 ++++++------- tailbone/templates/login.mako | 8 ++++---- tailbone/templates/master/form.mako | 2 +- tailbone/templates/people/index.mako | 16 +++++++-------- tailbone/templates/poser/reports/view.mako | 6 +++--- tailbone/templates/products/batch.mako | 20 +++++++++---------- tailbone/templates/products/index.mako | 8 ++++---- .../templates/purchases/credits/index.mako | 12 +++++------ tailbone/templates/receiving/view.mako | 8 ++++---- .../templates/reports/generated/delete.mako | 2 +- .../templates/reports/generated/view.mako | 2 +- tailbone/templates/reports/problems/view.mako | 2 +- tailbone/templates/roles/view.mako | 2 +- tailbone/templates/settings/email/index.mako | 8 ++++---- .../templates/tempmon/appliances/view.mako | 2 +- tailbone/templates/tempmon/clients/view.mako | 2 +- .../trainwreck/transactions/view.mako | 2 +- .../trainwreck/transactions/view_row.mako | 2 +- tailbone/templates/users/view.mako | 2 +- 28 files changed, 88 insertions(+), 88 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 9c445fec..d00a85ae 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1697,7 +1697,7 @@ 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.component_studly) + var = '{}CurrentData'.format(self.vue_component) results['checked_rows_code'] = '[{}]'.format( ', '.join(['{}[{}]'.format(var, i) for i in checked])) diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index 209fbb0c..a7808590 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -83,7 +83,7 @@ % if master.results_executable and master.has_perm('execute_multiple'): <script type="text/javascript"> - ${execute_form.component_studly}.methods.submit = function() { + ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } @@ -123,9 +123,9 @@ % if master.results_executable and master.has_perm('execute_multiple'): <script type="text/javascript"> - ${execute_form.component_studly}.data = function() { return ${execute_form.component_studly}Data } + ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } - Vue.component('${execute_form.component}', ${execute_form.component_studly}) + Vue.component('${execute_form.component}', ${execute_form.vue_component}) </script> % endif diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako index 7e4795a8..8ca32ce0 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.component_studly} = { + let ${form.vue_component} = { template: '#${form.component}-template', mixins: [SimpleRequestMixin], @@ -278,7 +278,7 @@ }, } - let ${form.component_studly}Data = { + let ${form.vue_component}Data = { submitting: false, productUPC: null, diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako index 0da755aa..bdb8709d 100644 --- a/tailbone/templates/batch/pos/view.mako +++ b/tailbone/templates/batch/pos/view.mako @@ -5,7 +5,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.taxesData = ${json.dumps(taxes_data)|n} + ${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n} </script> </%def> diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako index d25c8f16..63865bd5 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -5,12 +5,12 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.parsers = ${json.dumps(parsers_data)|n} + ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n} - ${form.component_studly}Data.vendorName = null - ${form.component_studly}Data.vendorNameReplacement = null + ${form.vue_component}Data.vendorName = null + ${form.vue_component}Data.vendorNameReplacement = null - ${form.component_studly}.watch.field_model_parser_key = function(val) { + ${form.vue_component}.watch.field_model_parser_key = function(val) { let parser = this.parsers[val] if (parser.vendor_uuid) { if (this.field_model_vendor_uuid != parser.vendor_uuid) { @@ -24,11 +24,11 @@ } } - ${form.component_studly}.methods.vendorLabelChanging = function(label) { + ${form.vue_component}.methods.vendorLabelChanging = function(label) { this.vendorNameReplacement = label } - ${form.component_studly}.methods.vendorChanged = function(uuid) { + ${form.vue_component}.methods.vendorChanged = function(uuid) { if (uuid) { this.vendorName = this.vendorNameReplacement this.vendorNameReplacement = null diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index 63cb9056..bef18cd4 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -285,7 +285,7 @@ } % if not batch.executed and master.has_perm('edit'): - ${form.component_studly}Data.togglingBatchComplete = false + ${form.vue_component}Data.togglingBatchComplete = false % endif % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): @@ -306,7 +306,7 @@ form.submit() } - ${upload_worksheet_form.component_studly}.methods.submit = function() { + ${upload_worksheet_form.vue_component}.methods.submit = function() { this.$refs.actualUploadForm.submit() } @@ -321,7 +321,7 @@ this.$refs.executeBatchForm.submit() } - ${execute_form.component_studly}.methods.submit = function() { + ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() } @@ -329,9 +329,9 @@ % if master.rows_bulk_deletable and not batch.executed and master.has_perm('delete_rows'): - ${rows_grid.component_studly}Data.deleteResultsShowDialog = false + ${rows_grid.vue_component}Data.deleteResultsShowDialog = false - ${rows_grid.component_studly}.methods.deleteResultsInit = function() { + ${rows_grid.vue_component}.methods.deleteResultsInit = function() { this.deleteResultsShowDialog = true } @@ -346,8 +346,8 @@ <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}) + ${upload_worksheet_form.vue_component}.data = function() { return ${upload_worksheet_form.vue_component}Data } + Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.vue_component}) </script> % endif @@ -356,8 +356,8 @@ <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}) + ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } + Vue.component('${execute_form.component}', ${execute_form.vue_component}) </script> % endif diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index 8b07bdb3..bbca9580 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -21,10 +21,10 @@ <script type="text/javascript"> % if expose_shoppers: - ${form.component_studly}Data.shoppers = ${json.dumps(shoppers_data)|n} + ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n} % endif % if expose_people: - ${form.component_studly}Data.peopleData = ${json.dumps(people_data)|n} + ${form.vue_component}Data.peopleData = ${json.dumps(people_data)|n} % endif ThisPage.methods.detachPerson = function(url) { diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index f7a6dd0a..8eaee69a 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -295,7 +295,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.eventsData = ${json.dumps(events_data)|n} + ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n} % if master.has_perm('confirm_price'): @@ -392,9 +392,9 @@ this.$refs.changeStatusForm.submit() } - ${form.component_studly}Data.changeFlaggedSubmitting = false + ${form.vue_component}Data.changeFlaggedSubmitting = false - ${form.component_studly}.methods.changeFlaggedSubmit = function() { + ${form.vue_component}.methods.changeFlaggedSubmit = function() { this.changeFlaggedSubmitting = true } diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako index 442f045f..f892f333 100644 --- a/tailbone/templates/departments/view.mako +++ b/tailbone/templates/departments/view.mako @@ -5,7 +5,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.employeesData = ${json.dumps(employees_data)|n} + ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n} </script> </%def> diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako index 2bc2a4e9..23526ed2 100644 --- a/tailbone/templates/importing/runjob.mako +++ b/tailbone/templates/importing/runjob.mako @@ -67,21 +67,21 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.submittingRun = false - ${form.component_studly}Data.submittingExplain = false - ${form.component_studly}Data.runJob = false + ${form.vue_component}Data.submittingRun = false + ${form.vue_component}Data.submittingExplain = false + ${form.vue_component}Data.runJob = false - ${form.component_studly}.methods.submitRun = function() { + ${form.vue_component}.methods.submitRun = function() { this.submittingRun = true this.runJob = true this.$nextTick(() => { - this.$refs.${form.component_studly}.submit() + this.$refs.${form.vue_component}.submit() }) } - ${form.component_studly}.methods.submitExplain = function() { + ${form.vue_component}.methods.submitExplain = function() { this.submittingExplain = true - this.$refs.${form.component_studly}.submit() + this.$refs.${form.vue_component}.submit() } </script> diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index d18323b5..f898660f 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -60,19 +60,19 @@ <%def name="modify_this_page_vars()"> <script type="text/javascript"> - ${form.component_studly}Data.usernameInput = null + ${form.vue_component}Data.usernameInput = null - ${form.component_studly}.mounted = function() { + ${form.vue_component}.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() { + ${form.vue_component}.beforeDestroy = function() { this.usernameInput.removeEventListener('keydown', this.usernameKeydown) } - ${form.component_studly}.methods.usernameKeydown = function(event) { + ${form.vue_component}.methods.usernameKeydown = function(event) { if (event.which == 13) { event.preventDefault() this.$refs.password.focus() diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index dc9743ea..fac18ee2 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -8,7 +8,7 @@ ## declare extra data needed by form % if form is not Undefined and getattr(form, 'json_data', None): % for key, value in form.json_data.items(): - ${form.component_studly}Data.${key} = ${json.dumps(value)|n} + ${form.vue_component}Data.${key} = ${json.dumps(value)|n} % endfor % endif diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index 9339dfd5..6ce14633 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -67,31 +67,31 @@ % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): - ${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}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}.computed.mergeRequestRemovingUUID = function() { + ${grid.vue_component}.computed.mergeRequestRemovingUUID = function() { if (this.mergeRequestRows.length) { return this.mergeRequestRows[0].uuid } return null } - ${grid.component_studly}.computed.mergeRequestKeepingUUID = function() { + ${grid.vue_component}.computed.mergeRequestKeepingUUID = function() { if (this.mergeRequestRows.length) { return this.mergeRequestRows[1].uuid } return null } - ${grid.component_studly}.methods.showMergeRequest = function() { + ${grid.vue_component}.methods.showMergeRequest = function() { this.mergeRequestRows = this.checkedRows this.mergeRequestShowDialog = true } - ${grid.component_studly}.methods.submitMergeRequest = function() { + ${grid.vue_component}.methods.submitMergeRequest = function() { this.mergeRequestSubmitting = true this.mergeRequestSubmitText = "Working, please wait..." } diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako index aac0c7ae..274a8806 100644 --- a/tailbone/templates/poser/reports/view.mako +++ b/tailbone/templates/poser/reports/view.mako @@ -67,11 +67,11 @@ % if master.has_perm('replace'): <script type="text/javascript"> - ${form.component_studly}Data.showUploadForm = false + ${form.vue_component}Data.showUploadForm = false - ${form.component_studly}Data.uploadFile = null + ${form.vue_component}Data.uploadFile = null - ${form.component_studly}Data.uploadSubmitting = false + ${form.vue_component}Data.uploadSubmitting = false </script> % endif diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index a4a4d503..66e38028 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.component_studly)})} + ${h.form(request.current_route_url(), **{'@submit': 'submit{}'.format(form.vue_component)})} ${h.csrf_token(request)} <section> @@ -43,8 +43,8 @@ <div class="buttons"> <b-button type="is-primary" native-type="submit" - :disabled="${form.component_studly}Submitting"> - {{ ${form.component_studly}ButtonText }} + :disabled="${form.vue_component}Submitting"> + {{ ${form.vue_component}ButtonText }} </b-button> <b-button tag="a" href="${url('products')}"> Cancel @@ -66,21 +66,21 @@ ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) - let ${form.component_studly} = { + let ${form.vue_component} = { template: '#${form.component}-template', methods: { ## TODO: deprecate / remove the latter option here % 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..." + submit${form.vue_component}() { + this.${form.vue_component}Submitting = true + this.${form.vue_component}ButtonText = "Working, please wait..." } % endif } } - let ${form.component_studly}Data = { + let ${form.vue_component}Data = { ## TODO: ugh, this seems pretty hacky. need to declare some data models ## for various field components to bind to... @@ -95,8 +95,8 @@ ## TODO: deprecate / remove the latter option here % 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}, + ${form.vue_component}Submitting: false, + ${form.vue_component}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 diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index 0d4bc410..b4731dee 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -41,11 +41,11 @@ % if label_profiles and master.has_perm('print_labels'): <script type="text/javascript"> - ${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}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}.methods.quickLabelPrint = function(row) { + ${grid.vue_component}.methods.quickLabelPrint = function(row) { let quantity = parseInt(this.quickLabelQuantity) if (isNaN(quantity)) { diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako index 4248d4ad..0cfbc031 100644 --- a/tailbone/templates/purchases/credits/index.mako +++ b/tailbone/templates/purchases/credits/index.mako @@ -63,17 +63,17 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${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}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}.methods.changeStatusInit = function() { + ${grid.vue_component}.methods.changeStatusInit = function() { this.changeStatusValue = null this.changeStatusShowDialog = true } - ${grid.component_studly}.methods.changeStatusSubmit = function() { + ${grid.vue_component}.methods.changeStatusSubmit = function() { this.changeStatusSubmitting = true this.$refs.changeStatusForm.submit() } diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 5f103d7f..45a8d66b 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -318,13 +318,13 @@ % if allow_edit_catalog_unit_cost: - ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) { + ${rows_grid.vue_component}.methods.catalogUnitCostClicked = function(row) { // start edit for clicked cell this.$refs['catalogUnitCost_' + row.uuid].startEdit() } - ${rows_grid.component_studly}.methods.catalogCostConfirmed = function(amount, index) { + ${rows_grid.vue_component}.methods.catalogCostConfirmed = function(amount, index) { // update display to indicate cost was confirmed this.addRowClass(index, 'catalog_cost_confirmed') @@ -353,13 +353,13 @@ % if allow_edit_invoice_unit_cost: - ${rows_grid.component_studly}.methods.invoiceUnitCostClicked = function(row) { + ${rows_grid.vue_component}.methods.invoiceUnitCostClicked = function(row) { // start edit for clicked cell this.$refs['invoiceUnitCost_' + row.uuid].startEdit() } - ${rows_grid.component_studly}.methods.invoiceCostConfirmed = function(amount, index) { + ${rows_grid.vue_component}.methods.invoiceCostConfirmed = function(amount, index) { // update display to indicate cost was confirmed this.addRowClass(index, 'invoice_cost_confirmed') diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako index 0c994ad0..bce54662 100644 --- a/tailbone/templates/reports/generated/delete.mako +++ b/tailbone/templates/reports/generated/delete.mako @@ -6,7 +6,7 @@ <script type="text/javascript"> % if params_data is not Undefined: - ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} + ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} % endif </script> diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako index 6260efba..e5bcc9e4 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -28,7 +28,7 @@ <script type="text/javascript"> % if params_data is not Undefined: - ${form.component_studly}Data.paramsData = ${json.dumps(params_data)|n} + ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n} % endif </script> diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index 026c73dc..1d5cb14f 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -67,7 +67,7 @@ <script type="text/javascript"> % if weekdays_data is not Undefined: - ${form.component_studly}Data.weekdaysData = ${json.dumps(weekdays_data)|n} + ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n} % endif ThisPageData.runReportShowDialog = false diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index 0f4ce472..0dc2956f 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -11,7 +11,7 @@ <script type="text/javascript"> % if users_data is not Undefined: - ${form.component_studly}Data.usersData = ${json.dumps(users_data)|n} + ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n} % endif ThisPage.methods.detachPerson = function(url) { diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako index dbc963b9..050a5833 100644 --- a/tailbone/templates/settings/email/index.mako +++ b/tailbone/templates/settings/email/index.mako @@ -26,9 +26,9 @@ this.$refs.grid.showEmails = this.showEmails } - ${grid.component_studly}Data.showEmails = 'available' + ${grid.vue_component}Data.showEmails = 'available' - ${grid.component_studly}.computed.visibleData = function() { + ${grid.vue_component}.computed.visibleData = function() { if (this.showEmails == 'available') { return this.data.filter(email => email.hidden == 'No') @@ -41,11 +41,11 @@ return this.data } - ${grid.component_studly}.methods.renderLabelToggleHidden = function(row) { + ${grid.vue_component}.methods.renderLabelToggleHidden = function(row) { return row.hidden == 'Yes' ? "Un-hide" : "Hide" } - ${grid.component_studly}.methods.toggleHidden = function(row) { + ${grid.vue_component}.methods.toggleHidden = function(row) { let url = '${url('{}.toggle_hidden'.format(route_prefix))}' let params = { key: row.key, diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako index 07a524b8..7dd9314a 100644 --- a/tailbone/templates/tempmon/appliances/view.mako +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -12,7 +12,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n} + ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} </script> </%def> diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako index cff22fed..b1db423b 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -26,7 +26,7 @@ ${parent.modify_this_page_vars()} <script type="text/javascript"> - ${form.component_studly}Data.probesData = ${json.dumps(probes_data)|n} + ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n} </script> </%def> diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako index 2be51c7d..02950941 100644 --- a/tailbone/templates/trainwreck/transactions/view.mako +++ b/tailbone/templates/trainwreck/transactions/view.mako @@ -6,7 +6,7 @@ <script type="text/javascript"> % if custorder_xref_markers_data is not Undefined: - ${form.component_studly}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} + ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n} % endif </script> diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako index 9abcb8ba..9c76f7bd 100644 --- a/tailbone/templates/trainwreck/transactions/view_row.mako +++ b/tailbone/templates/trainwreck/transactions/view_row.mako @@ -6,7 +6,7 @@ <script type="text/javascript"> % if discounts_data is not Undefined: - ${form.component_studly}Data.discountsData = ${json.dumps(discounts_data)|n} + ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n} % endif </script> diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index ed2b5f16..06087927 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -81,7 +81,7 @@ % if master.has_perm('manage_api_tokens'): <script type="text/javascript"> - ${form.component_studly}.props.apiTokens = null + ${form.vue_component}.props.apiTokens = null ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n} From 0eeeb4bd35981ee1ff4213f0b3cbd1b5dd774baf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 11:09:49 -0500 Subject: [PATCH 124/211] fix: prefer attr over key lookup when getting model values applies to both forms and grids. the base model class can still handle `obj[key]` but now it is limited to the column fields only, no association proxies. so, better to just try `getattr(obj, key)` first and only fall back to the other if it fails. unless the obj is clearly a dict in which case try `obj[key]` only --- tailbone/forms/core.py | 13 ++++++++----- tailbone/grids/core.py | 10 ++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 704d3b54..2f1c9370 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1359,12 +1359,15 @@ class Form(object): def obtain_value(self, record, field_name): if record: - try: + + if isinstance(record, dict): return record[field_name] - except KeyError: - return None - except TypeError: - return getattr(record, field_name, None) + + try: + return getattr(record, field_name) + except AttributeError: + pass + return record[field_name] # TODO: is this always safe to do? elif self.defaults and field_name in self.defaults: diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index d00a85ae..3caf909c 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -586,12 +586,14 @@ class Grid(WuttaGrid): if isinstance(obj, sa.engine.Row): return obj._mapping[column_name] - try: + if isinstance(obj, dict): return obj[column_name] - except KeyError: + + try: + return getattr(obj, column_name) + except AttributeError: pass - except TypeError: - return getattr(obj, column_name, None) + return obj[column_name] def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) From f5661fe349a456de2ef68e56b9afed02ad765fa7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 11:56:46 -0500 Subject: [PATCH 125/211] fix: sort on frontend for appinfo package listing grid --- tailbone/views/settings.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 9d7f6e02..bda62ccc 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -83,14 +83,15 @@ class AppInfoView(MasterView): def configure_grid(self, g): super().configure_grid(g) - g.sorters['name'] = g.make_simple_sorter('name', foldcase=True) + # sort on frontend + g.sort_on_backend = False + g.sort_multiple = False g.set_sort_defaults('name') + + # name g.set_searchable('name') - g.sorters['version'] = g.make_simple_sorter('version', foldcase=True) - - g.sorters['editable_project_location'] = g.make_simple_sorter( - 'editable_project_location', foldcase=True) + # editable_project_location g.set_searchable('editable_project_location') def template_kwargs_index(self, **kwargs): From 41945c5e3777958d9940e94add680a0fd2e8d476 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 12:01:42 -0500 Subject: [PATCH 126/211] =?UTF-8?q?bump:=20version=200.19.1=20=E2=86=92=20?= =?UTF-8?q?0.19.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce64ec60..1fe71f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index fa33a2df..8f840642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.19.1" +version = "0.19.2" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.10.0", + "WuttaWeb>=0.10.1", "zope.sqlalchemy>=1.5", ] From 15ab0c959244c4de7a515e647fb60b8dd22d64b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 13:48:18 -0500 Subject: [PATCH 127/211] fix: add pager stats to all grid vue data (fixes view history) also various other tweaks to modernize --- tailbone/grids/core.py | 6 +++++- tailbone/templates/grids/complete.mako | 2 +- tailbone/templates/master/view.mako | 30 +++++++++----------------- tailbone/views/master.py | 11 +++++----- 4 files changed, 21 insertions(+), 28 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 3caf909c..6ec55987 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -237,7 +237,7 @@ class Grid(WuttaGrid): kwargs.setdefault('sort_defaults', [(sortkey, sortdir)]) if kwargs.get('pageable'): - warnings.warn("component param is deprecated for Grid(); " + warnings.warn("pageable param is deprecated for Grid(); " "please use vue_tagname param instead", DeprecationWarning, stacklevel=2) kwargs.setdefault('paginated', kwargs.pop('pageable')) @@ -1703,6 +1703,10 @@ class Grid(WuttaGrid): 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: results['total_items'] = self.pager.item_count results['per_page'] = self.pager.items_per_page diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 8dc2d6dc..c136273b 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -115,7 +115,7 @@ ## paging % if grid.paginated: paginated - pagination-size="is-small" + pagination-size="${'small' if request.use_oruga else 'is-small'}" :per-page="perPage" :current-page="currentPage" @page-change="onPageChange" diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index a61020f3..37f57237 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -120,9 +120,7 @@ </p> </div> - <versions-grid ref="versionsGrid" - @view-revision="viewRevision"> - </versions-grid> + ${versions_grid.render_vue_tag(ref='versionsGrid', **{'@view-revision': 'viewRevision'})} <${b}-modal :width="1200" % if request.use_oruga: @@ -237,17 +235,16 @@ </%def> <%def name="render_row_grid_component()"> - <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid> + ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')} </%def> <%def name="render_this_page_template()"> % if getattr(master, 'has_rows', False): - ## TODO: stop using |n filter - ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n} + ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))} % endif ${parent.render_this_page_template()} % if expose_versions: - ${versions_grid.render_complete()|n} + ${versions_grid.render_vue_template()} % endif </%def> @@ -338,19 +335,12 @@ <%def name="finalize_this_page_vars()"> ${parent.finalize_this_page_vars()} - <script type="text/javascript"> - - % if getattr(master, 'has_rows', False): - 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> + % if getattr(master, 'has_rows', False): + ${rows_grid.render_vue_finalize()} + % endif + % if expose_versions: + ${versions_grid.render_vue_finalize()} + % endif </%def> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index dde72106..ac74a070 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -347,8 +347,6 @@ class MasterView(View): # return grid data only, if partial page was requested if self.request.GET.get('partial'): context = grid.get_table_data() - if grid.paginated and grid.paginate_on_backend: - context['pager_stats'] = grid.get_vue_pager_stats() return self.json_response(context) context = { @@ -587,7 +585,8 @@ class MasterView(View): 'filterable': self.rows_filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.rows_sortable, - 'pageable': self.rows_pageable, + 'sort_multiple': not self.request.use_oruga, + 'paginated': self.rows_pageable, 'extra_row_class': self.row_grid_extra_class, 'url': lambda obj: self.get_row_action_url('view', obj), } @@ -675,7 +674,7 @@ class MasterView(View): defaults = { 'model_class': continuum.transaction_class(self.get_model_class()), 'width': 'full', - 'pageable': True, + 'paginated': True, 'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id), } if 'actions' not in kwargs: @@ -1387,8 +1386,8 @@ class MasterView(View): 'vue_tagname': 'versions-grid', 'ajax_data_url': self.get_action_url('revisions_data', obj), 'sortable': True, - 'default_sortkey': 'changed', - 'default_sortdir': 'desc', + 'sort_multiple': not self.request.use_oruga, + 'sort_defaults': ('changed', 'desc'), 'actions': [ self.make_action('view', icon='eye', url='#', click_handler='viewRevision(props.row)'), From b762a0782a1b677817166609ee8b94bca872a7e2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 13:57:36 -0500 Subject: [PATCH 128/211] =?UTF-8?q?bump:=20version=200.19.2=20=E2=86=92=20?= =?UTF-8?q?0.19.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe71f3f..c8017445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.19.3 (2024-08-19) + +### Fix + +- add pager stats to all grid vue data (fixes view history) + ## v0.19.2 (2024-08-19) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 8f840642..3e07abaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.19.2" +version = "0.19.3" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.10.1", + "WuttaWeb>=0.10.2", "zope.sqlalchemy>=1.5", ] From d29b8403435237effd5ca2d122a9fb00ff6896b2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 14:38:41 -0500 Subject: [PATCH 129/211] fix: avoid deprecated reference to app db engine --- tailbone/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/app.py b/tailbone/app.py index 626c9206..ad9663cf 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -63,8 +63,8 @@ def make_rattail_config(settings): settings['wutta_config'] = rattail_config # configure database sessions - if hasattr(rattail_config, 'rattail_engine'): - tailbone.db.Session.configure(bind=rattail_config.rattail_engine) + if hasattr(rattail_config, 'appdb_engine'): + tailbone.db.Session.configure(bind=rattail_config.appdb_engine) if hasattr(rattail_config, 'trainwreck_engine'): tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine) if hasattr(rattail_config, 'tempmon_engine'): From 1ec1eba49681867aac1e24e11d3b89ed8bba060e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 19 Aug 2024 21:30:58 -0500 Subject: [PATCH 130/211] feat: refactor templates to simplify base/page/form structure to mimic what has been done in wuttaweb --- tailbone/templates/appinfo/configure.mako | 9 +- tailbone/templates/appinfo/index.mako | 11 +- tailbone/templates/appsettings.mako | 20 +- tailbone/templates/base.mako | 164 +++++---- tailbone/templates/batch/index.mako | 36 +- .../batch/inventory/desktop_form.mako | 11 +- tailbone/templates/batch/pos/view.mako | 10 +- .../batch/vendorcatalog/configure.mako | 11 +- .../templates/batch/vendorcatalog/create.mako | 9 +- tailbone/templates/batch/view.mako | 58 ++-- tailbone/templates/configure-menus.mako | 9 +- tailbone/templates/configure.mako | 9 +- tailbone/templates/customers/configure.mako | 9 +- .../templates/customers/pending/view.mako | 8 +- tailbone/templates/customers/view.mako | 8 +- tailbone/templates/custorders/create.mako | 18 +- tailbone/templates/custorders/items/view.mako | 8 +- .../templates/datasync/changes/index.mako | 9 +- tailbone/templates/datasync/configure.mako | 9 +- tailbone/templates/datasync/status.mako | 8 +- tailbone/templates/departments/view.mako | 10 +- tailbone/templates/form.mako | 20 +- tailbone/templates/generate_feature.mako | 9 +- tailbone/templates/importing/configure.mako | 9 +- tailbone/templates/importing/runjob.mako | 8 +- tailbone/templates/login.mako | 8 +- tailbone/templates/luigi/configure.mako | 9 +- tailbone/templates/luigi/index.mako | 9 +- tailbone/templates/master/clone.mako | 9 +- tailbone/templates/master/delete.mako | 7 +- tailbone/templates/master/form.mako | 9 +- tailbone/templates/master/index.mako | 44 +-- tailbone/templates/master/merge.mako | 23 +- tailbone/templates/master/versions.mako | 31 +- tailbone/templates/master/view.mako | 54 ++- tailbone/templates/members/configure.mako | 9 +- tailbone/templates/messages/create.mako | 13 +- tailbone/templates/messages/index.mako | 17 +- tailbone/templates/messages/view.mako | 15 +- tailbone/templates/ordering/view.mako | 21 +- tailbone/templates/ordering/worksheet.mako | 25 +- tailbone/templates/page.mako | 96 +++--- tailbone/templates/people/index.mako | 8 +- .../templates/people/merge-requests/view.mako | 8 +- tailbone/templates/people/view.mako | 30 +- tailbone/templates/people/view_profile.mako | 317 +++++++++--------- tailbone/templates/poser/reports/view.mako | 20 +- tailbone/templates/poser/setup.mako | 11 +- .../templates/principal/find_by_perm.mako | 53 ++- tailbone/templates/products/batch.mako | 9 +- tailbone/templates/products/configure.mako | 9 +- tailbone/templates/products/index.mako | 9 +- tailbone/templates/products/pending/view.mako | 23 +- tailbone/templates/products/view.mako | 9 +- .../templates/purchases/credits/index.mako | 9 +- tailbone/templates/receiving/view.mako | 26 +- tailbone/templates/receiving/view_row.mako | 9 +- .../templates/reports/generated/choose.mako | 13 +- .../templates/reports/generated/delete.mako | 11 +- .../templates/reports/generated/view.mako | 11 +- tailbone/templates/reports/inventory.mako | 11 +- tailbone/templates/reports/ordering.mako | 9 +- tailbone/templates/reports/problems/view.mako | 9 +- tailbone/templates/roles/create.mako | 12 +- tailbone/templates/roles/edit.mako | 12 +- tailbone/templates/roles/view.mako | 8 +- .../templates/settings/email/configure.mako | 9 +- tailbone/templates/settings/email/index.mako | 8 +- tailbone/templates/settings/email/view.mako | 21 +- tailbone/templates/tables/create.mako | 9 +- .../templates/tempmon/appliances/view.mako | 11 +- tailbone/templates/tempmon/clients/view.mako | 11 +- tailbone/templates/tempmon/dashboard.mako | 9 +- tailbone/templates/tempmon/probes/graph.mako | 9 +- .../templates/themes/butterball/base.mako | 100 ++++-- .../trainwreck/transactions/configure.mako | 11 +- .../trainwreck/transactions/rollover.mako | 11 +- .../trainwreck/transactions/view.mako | 10 +- .../trainwreck/transactions/view_row.mako | 11 +- .../templates/units-of-measure/index.mako | 19 +- tailbone/templates/upgrades/configure.mako | 9 +- tailbone/templates/upgrades/view.mako | 21 +- tailbone/templates/users/preferences.mako | 11 +- tailbone/templates/users/view.mako | 9 +- tailbone/templates/vendors/configure.mako | 11 +- tailbone/templates/views/model/create.mako | 9 +- tailbone/templates/workorders/view.mako | 9 +- 87 files changed, 818 insertions(+), 1045 deletions(-) diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index aab180c4..4794f00b 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -213,9 +213,9 @@ </div> </%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.weblibs = ${json.dumps(weblibs)|n} @@ -245,6 +245,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 73f53920..68244300 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -114,14 +114,9 @@ </${b}-collapse> </%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.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 4f935956..ba667e0e 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_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="app-settings-template"> <div class="form"> @@ -150,19 +150,18 @@ </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_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> Vue.component('app-settings', { template: '#app-settings-template', @@ -193,6 +192,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 8e3b7785..a0e58e22 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -34,17 +34,21 @@ </head> <body> - ${declare_formposter_mixin()} - - ${self.body()} - - <div id="whole-page-app"> + <div id="app" style="height: 100%;"> <whole-page></whole-page> </div> - ${self.render_whole_page_template()} - ${self.make_whole_page_component()} - ${self.make_whole_page_app()} + ## 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()} </body> </html> @@ -181,7 +185,7 @@ <%def name="head_tags()"></%def> -<%def name="render_whole_page_template()"> +<%def name="render_vue_template_whole_page()"> <script type="text/x-template" id="whole-page-template"> <div> <header> @@ -749,11 +753,8 @@ % endif </%def> -<%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"> +<%def name="render_vue_script_whole_page()"> + <script> let WholePage = { template: '#whole-page-template', @@ -889,57 +890,6 @@ </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> @@ -961,3 +911,87 @@ </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_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/batch/index.mako b/tailbone/templates/batch/index.mako index a7808590..a1b11b89 100644 --- a/tailbone/templates/batch/index.mako +++ b/tailbone/templates/batch/index.mako @@ -64,10 +64,17 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.results_executable and master.has_perm('execute_multiple'): + ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + % endif +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.results_refreshable and master.has_perm('refresh'): - <script type="text/javascript"> + <script> TailboneGridData.refreshResultsButtonText = "Refresh Results" TailboneGridData.refreshResultsButtonDisabled = false @@ -81,7 +88,7 @@ </script> % endif % if master.results_executable and master.has_perm('execute_multiple'): - <script type="text/javascript"> + <script> ${execute_form.vue_component}.methods.submit = function() { this.$refs.actualExecuteForm.submit() @@ -118,25 +125,12 @@ % endif </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if master.results_executable and master.has_perm('execute_multiple'): - <script type="text/javascript"> - + <script> ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } - - Vue.component('${execute_form.component}', ${execute_form.vue_component}) - + Vue.component('${execute_form.vue_tagname}', ${execute_form.vue_component}) </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 8ca32ce0..cddaa2c5 100644 --- a/tailbone/templates/batch/inventory/desktop_form.mako +++ b/tailbone/templates/batch/inventory/desktop_form.mako @@ -297,14 +297,9 @@ </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.toggleCompleteSubmitting = false - </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako index bdb8709d..5ecabd4d 100644 --- a/tailbone/templates/batch/pos/view.mako +++ b/tailbone/templates/batch/pos/view.mako @@ -1,13 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/view.mako" /> -<%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> ${form.vue_component}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 0d57053e..4f91cb02 100644 --- a/tailbone/templates/batch/vendorcatalog/configure.mako +++ b/tailbone/templates/batch/vendorcatalog/configure.mako @@ -39,14 +39,9 @@ </div> </%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.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 63865bd5..d9d62bd1 100644 --- a/tailbone/templates/batch/vendorcatalog/create.mako +++ b/tailbone/templates/batch/vendorcatalog/create.mako @@ -1,9 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/batch/create.mako" /> -<%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> ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n} @@ -37,6 +37,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index bef18cd4..cdfa9ba7 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -149,12 +149,6 @@ </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()} @@ -197,16 +191,6 @@ </%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"> @@ -267,9 +251,27 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%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_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> + +## DEPRECATED; remains for back-compat +## nb. this is called by parent template, /form.mako +<%def name="render_form_template()"> + ## TODO: should use self.render_form_buttons() + ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n} + ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n} @@ -340,28 +342,18 @@ </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - <script type="text/javascript"> - - ## UploadForm + <script> ${upload_worksheet_form.vue_component}.data = function() { return ${upload_worksheet_form.vue_component}Data } Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.vue_component}) - </script> % endif - % if execute_enabled and master.has_perm('execute'): - <script type="text/javascript"> - - ## ExecuteForm + <script> ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } Vue.component('${execute_form.component}', ${execute_form.vue_component}) - </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako index c0200912..c7f46d21 100644 --- a/tailbone/templates/configure-menus.mako +++ b/tailbone/templates/configure-menus.mako @@ -208,9 +208,9 @@ % endif </%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.menuSequence = ${json.dumps([m['key'] for m in menus])|n} @@ -443,6 +443,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index f33779c8..272aadce 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -205,9 +205,9 @@ ${h.end_form()} </%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> % if simple_settings is not Undefined: ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n} @@ -293,6 +293,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako index e68f4543..1a6dca8b 100644 --- a/tailbone/templates/customers/configure.mako +++ b/tailbone/templates/customers/configure.mako @@ -88,9 +88,9 @@ </%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> ThisPage.methods.getLabelForKey = function(key) { switch (key) { @@ -111,6 +111,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/customers/pending/view.mako b/tailbone/templates/customers/pending/view.mako index e9e54c99..1cea9d1f 100644 --- a/tailbone/templates/customers/pending/view.mako +++ b/tailbone/templates/customers/pending/view.mako @@ -106,9 +106,9 @@ % endif </%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.resolvePersonShowDialog = false ThisPageData.resolvePersonUUID = null @@ -139,5 +139,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako index bbca9580..490e4757 100644 --- a/tailbone/templates/customers/view.mako +++ b/tailbone/templates/customers/view.mako @@ -16,9 +16,9 @@ </div> </%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> % if expose_shoppers: ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n} @@ -36,5 +36,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 63505422..382a121f 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -47,10 +47,9 @@ </div> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} ${product_lookup.tailbone_product_lookup_template()} - <script type="text/x-template" id="customer-order-creator-template"> <div> @@ -1265,12 +1264,7 @@ </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - ${product_lookup.tailbone_product_lookup_component()} - <script type="text/javascript"> + <script> const CustomerOrderCreator = { template: '#customer-order-creator-template', @@ -2406,5 +2400,7 @@ </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${product_lookup.tailbone_product_lookup_component()} +</%def> diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako index 8eaee69a..4cc92bbf 100644 --- a/tailbone/templates/custorders/items/view.mako +++ b/tailbone/templates/custorders/items/view.mako @@ -291,9 +291,9 @@ % endif </%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> ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n} @@ -448,5 +448,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako index 6d171619..86f5c121 100644 --- a/tailbone/templates/datasync/changes/index.mako +++ b/tailbone/templates/datasync/changes/index.mako @@ -26,9 +26,9 @@ </%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> % if request.has_perm('datasync.restart'): TailboneGridData.restartDatasyncFormSubmitting = false @@ -50,6 +50,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 7922d189..3651d0c4 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -599,9 +599,9 @@ </%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.showConfigFilesNote = false ThisPageData.profilesData = ${json.dumps(profiles_data)|n} @@ -982,6 +982,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako index c782dec6..e14686f8 100644 --- a/tailbone/templates/datasync/status.mako +++ b/tailbone/templates/datasync/status.mako @@ -115,8 +115,9 @@ </${b}-table> </%def> -<%def name="modify_this_page_vars()"> - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.processInfo = ${json.dumps(process_info)|n} @@ -171,6 +172,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako index f892f333..c5c39cbb 100644 --- a/tailbone/templates/departments/view.mako +++ b/tailbone/templates/departments/view.mako @@ -1,13 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%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> ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n} - </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index fec721fd..3bb04257 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -90,15 +90,15 @@ <%def name="before_object_helpers()"></%def> -<%def name="render_this_page_template()"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % if form is not Undefined: ${self.render_form_template()} % endif - ${parent.render_this_page_template()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if main_form_collapsible: <script> ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'} @@ -106,18 +106,12 @@ % endif </%def> -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if form is not Undefined: - <script type="text/javascript"> - + <script> ${form.vue_component}.data = function() { return ${form.vue_component}Data } - Vue.component('${form.vue_tagname}', ${form.vue_component}) - </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako index 18a26f58..0f2a9f7b 100644 --- a/tailbone/templates/generate_feature.mako +++ b/tailbone/templates/generate_feature.mako @@ -276,9 +276,9 @@ </%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.featureType = ${json.dumps(feature_type)|n} ThisPageData.resultGenerated = ${json.dumps(bool(result))|n} @@ -385,6 +385,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako index 0396745a..2445341d 100644 --- a/tailbone/templates/importing/configure.mako +++ b/tailbone/templates/importing/configure.mako @@ -144,9 +144,9 @@ </b-modal> </%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.handlersData = ${json.dumps(handlers_data)|n} @@ -203,6 +203,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako index 23526ed2..a9625bc3 100644 --- a/tailbone/templates/importing/runjob.mako +++ b/tailbone/templates/importing/runjob.mako @@ -63,9 +63,9 @@ </div> </%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> ${form.vue_component}Data.submittingRun = false ${form.vue_component}Data.submittingExplain = false @@ -86,5 +86,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index f898660f..3eb46403 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -57,8 +57,9 @@ </div> </%def> -<%def name="modify_this_page_vars()"> - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ${form.vue_component}Data.usernameInput = null @@ -81,6 +82,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako index 49060ceb..de364828 100644 --- a/tailbone/templates/luigi/configure.mako +++ b/tailbone/templates/luigi/configure.mako @@ -297,9 +297,9 @@ </%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.overnightTasks = ${json.dumps(overnight_tasks)|n} ThisPageData.overnightTaskShowDialog = false @@ -425,6 +425,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako index b5134c25..0dd72d01 100644 --- a/tailbone/templates/luigi/index.mako +++ b/tailbone/templates/luigi/index.mako @@ -255,9 +255,9 @@ </div> </%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> % if master.has_perm('restart_scheduler'): @@ -374,6 +374,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako index 59d6aea2..4c7e4662 100644 --- a/tailbone/templates/master/clone.mako +++ b/tailbone/templates/master/clone.mako @@ -34,9 +34,9 @@ ${h.end_form()} </%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> TailboneFormData.formSubmitting = false TailboneFormData.submitButtonText = "Yes, please clone away" @@ -48,6 +48,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako index c6187d55..d2f517d9 100644 --- a/tailbone/templates/master/delete.mako +++ b/tailbone/templates/master/delete.mako @@ -33,8 +33,8 @@ ${h.end_form()} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} <script> ${form.vue_component}Data.formSubmitting = false @@ -45,6 +45,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index fac18ee2..17063c21 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -1,9 +1,9 @@ ## -*- coding: utf-8; -*- <%inherit file="/form.mako" /> -<%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> ## declare extra data needed by form % if form is not Undefined and getattr(form, 'json_data', None): @@ -28,6 +28,3 @@ % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 81c11213..a2d26c60 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -265,6 +265,11 @@ </%def> +## DEPRECATED; remains for back-compat +<%def name="render_this_page()"> + ${self.page_content()} +</%def> + <%def name="page_content()"> % if download_results_path: @@ -290,34 +295,28 @@ % endif </%def> -<%def name="make_grid_component()"> - ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} -</%def> - <%def name="render_grid_component()"> ${grid.render_vue_tag()} </%def> -<%def name="make_this_page_component()"> +############################## +## vue components +############################## - ## define grid +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + + ## DEPRECATED; called for back-compat ${self.make_grid_component()} - - ${parent.make_this_page_component()} - - ## finalize grid - <script> - ${grid.vue_component}.data = function() { return ${grid.vue_component}Data } - Vue.component('${grid.vue_tagname}', ${grid.vue_component}) - </script> </%def> -<%def name="render_this_page()"> - ${self.page_content()} +## DEPRECATED; remains for back-compat +<%def name="make_grid_component()"> + ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} <script type="text/javascript"> % if getattr(master, 'supports_grid_totals', False): @@ -624,5 +623,10 @@ </script> </%def> - -${parent.body()} +<%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> diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako index 5d90043f..487d258d 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_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="merge-buttons-template"> <div class="level" style="margin-top: 2em;"> @@ -147,11 +147,7 @@ </div> </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> + <script> const MergeButtons = { template: '#merge-buttons-template', @@ -175,12 +171,13 @@ } } - Vue.component('merge-buttons', MergeButtons) - - <% request.register_component('merge-buttons', 'MergeButtons') %> - </script> </%def> - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('merge-buttons', MergeButtons) + <% request.register_component('merge-buttons', 'MergeButtons') %> + </script> +</%def> diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako index 307674b8..a6bb14f0 100644 --- a/tailbone/templates/master/versions.mako +++ b/tailbone/templates/master/versions.mako @@ -16,27 +16,16 @@ ${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()"> - <tailbone-grid :csrftoken="csrftoken"> - </tailbone-grid> + ${grid.render_vue_tag(**{':csrftoken': 'csrftoken'})} </%def> -${parent.body()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${grid.render_vue_template()} +</%def> + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + ${grid.render_vue_finalize()} +</%def> diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 37f57237..0a1f9c62 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -238,21 +238,34 @@ ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')} </%def> -<%def name="render_this_page_template()"> +<%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))} % endif - ${parent.render_this_page_template()} % if expose_versions: ${versions_grid.render_vue_template()} % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - % if expose_versions: - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> + % 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 @@ -307,34 +320,12 @@ this.viewVersionShowAllFields = !this.viewVersionShowAllFields } - </script> - % endif -</%def> - -<%def name="modify_whole_page_vars()"> - ${parent.modify_whole_page_vars()} - <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 - % endif - </script> </%def> -<%def name="finalize_this_page_vars()"> - ${parent.finalize_this_page_vars()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if getattr(master, 'has_rows', False): ${rows_grid.render_vue_finalize()} % endif @@ -342,6 +333,3 @@ ${versions_grid.render_vue_finalize()} % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako index 465bf611..f1f0e39f 100644 --- a/tailbone/templates/members/configure.mako +++ b/tailbone/templates/members/configure.mako @@ -52,9 +52,9 @@ </div> </%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> ThisPage.methods.getLabelForKey = function(key) { switch (key) { @@ -75,6 +75,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako index 4a15573b..39236f75 100644 --- a/tailbone/templates/messages/create.mako +++ b/tailbone/templates/messages/create.mako @@ -32,14 +32,14 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} ${message_recipients_template()} </%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> TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n}) TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n} @@ -59,6 +59,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako index 3fc82fd3..eaa4b6c9 100644 --- a/tailbone/templates/messages/index.mako +++ b/tailbone/templates/messages/index.mako @@ -22,15 +22,15 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if request.matched_route.name in ('messages.inbox', 'messages.archive'): - <script type="text/javascript"> + <script> - TailboneGridData.moveMessagesSubmitting = false - TailboneGridData.moveMessagesText = null + ${grid.vue_component}Data.moveMessagesSubmitting = false + ${grid.vue_component}Data.moveMessagesText = null - TailboneGrid.computed.moveMessagesTextCurrent = function() { + ${grid.vue_component}.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'}" } - TailboneGrid.methods.moveMessagesSubmit = function() { + ${grid.vue_component}.methods.moveMessagesSubmit = function() { this.moveMessagesSubmitting = true this.moveMessagesText = "Working, please wait..." } @@ -46,6 +46,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako index 2e2baa60..36418698 100644 --- a/tailbone/templates/messages/view.mako +++ b/tailbone/templates/messages/view.mako @@ -82,22 +82,19 @@ </div> </%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> - TailboneFormData.showingAllRecipients = false + ${form.vue_component}Data.showingAllRecipients = false - TailboneForm.methods.showMoreRecipients = function() { + ${form.vue_component}.methods.showMoreRecipients = function() { this.showingAllRecipients = true } - TailboneForm.methods.hideMoreRecipients = function() { + ${form.vue_component}.methods.hideMoreRecipients = function() { this.showingAllRecipients = false } </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index aed6fd75..584559c1 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -21,8 +21,8 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} % 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_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): - <script type="text/javascript"> + <script> let OrderingScanner = { template: '#ordering-scanner-template', @@ -408,16 +408,11 @@ % endif </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} % if not batch.executed and not batch.complete and master.has_perm('edit_row'): - <script type="text/javascript"> - + <script> 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 ca1abf6e..cb98c48f 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -199,9 +199,8 @@ <ordering-worksheet></ordering-worksheet> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="ordering-worksheet-template"> <div> <div class="form-wrapper"> @@ -239,11 +238,7 @@ ${self.order_form_grid()} </div> </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} - <script type="text/javascript"> + <script> const OrderingWorksheet = { template: '#ordering-worksheet-template', @@ -298,14 +293,12 @@ }, } - Vue.component('ordering-worksheet', OrderingWorksheet) - </script> </%def> - -############################## -## page body -############################## - -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('ordering-worksheet', OrderingWorksheet) + </script> +</%def> diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 17d87c9a..54b47278 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -1,42 +1,26 @@ ## -*- coding: utf-8; -*- <%inherit file="/base.mako" /> -<%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 name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${self.render_vue_template_this_page()} </%def> -<%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 name="render_vue_template_this_page()"> + ## DEPRECATED; called for back-compat + ${self.render_this_page_template()} </%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> -</%def> + <script> -<%def name="declare_this_page_vars()"> - <script type="text/javascript"> - - let ThisPage = { + const ThisPage = { template: '#this-page-template', mixins: [SimpleRequestMixin], props: { @@ -52,7 +36,7 @@ }, } - let ThisPageData = { + const ThisPageData = { ## TODO: should find a better way to handle CSRF token csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, } @@ -60,29 +44,63 @@ </script> </%def> -<%def name="modify_this_page_vars()"> - ## NOTE: if you override this, must use <script> tags +## 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> -<%def name="finalize_this_page_vars()"> - ## NOTE: if you override this, must use <script> tags +## 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> <%def name="make_this_page_component()"> - ${self.declare_this_page_vars()} - ${self.modify_this_page_vars()} ${self.finalize_this_page_vars()} - - <script type="text/javascript"> - + <script> ThisPage.data = function() { return ThisPageData } - Vue.component('this-page', ThisPage) <% request.register_component('this-page', 'ThisPage') %> - </script> </%def> +############################## +## DEPRECATED +############################## -${self.render_this_page_template()} -${self.make_this_page_component()} +<%def name="declare_this_page_vars()"></%def> + +<%def name="modify_this_page_vars()"></%def> + +<%def name="finalize_this_page_vars()"></%def> diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako index 6ce14633..cd6fddf1 100644 --- a/tailbone/templates/people/index.mako +++ b/tailbone/templates/people/index.mako @@ -61,9 +61,9 @@ ${parent.grid_tools()} </%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> % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'): @@ -100,5 +100,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako index 9e8905cf..e2db1476 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_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if not instance.merged and request.has_perm('people.merge'): - <script type="text/javascript"> + <script> ThisPageData.mergeFormButtonText = "Perform Merge" ThisPageData.mergeFormSubmitting = false @@ -34,5 +34,3 @@ </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako index d28d7558..15c669fa 100644 --- a/tailbone/templates/people/view.mako +++ b/tailbone/templates/people/view.mako @@ -2,6 +2,16 @@ <%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])} @@ -13,9 +23,9 @@ </div> </%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> ${form.vue_component}.methods.clickMakeUser = function(event) { this.$emit('make-user') @@ -29,17 +39,3 @@ </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 cdb6c5cc..6ca5a84c 100644 --- a/tailbone/templates/people/view_profile.mako +++ b/tailbone/templates/people/view_profile.mako @@ -1966,30 +1966,97 @@ </div> </script> -</%def> + <script> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} - ${self.render_personal_tab_template()} + 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}, - % if expose_members: - ${self.render_member_tab_template()} - % endif + % if request.has_perm('people_profile.view_versions'): + loadingRevisions: false, + showingRevisionDialog: false, + revision: {}, + revisionShowAllFields: false, + % 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()} + 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() { - % if expose_transactions: - ${transactions_grid.render_complete(allow_save_defaults=False)|n} - ${self.render_transactions_tab_template()} - % endif + // 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: { - ${self.render_user_tab_template()} - ${self.render_profile_info_template()} + 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> </%def> <%def name="declare_personal_tab_vars()"> @@ -3022,114 +3089,46 @@ </script> </%def> -<%def name="declare_profile_info_vars()"> - <script type="text/javascript"> - - 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}, - - % 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> -</%def> - <%def name="make_profile_info_component()"> - ${self.declare_profile_info_vars()} - <script type="text/javascript"> + ## DEPRECATED; called for back-compat + ${self.declare_profile_info_vars()} + + <script> ProfileInfo.data = function() { return ProfileInfoData } Vue.component('profile-info', ProfileInfo) <% request.register_component('profile-info', 'ProfileInfo') %> - </script> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} + + ${self.render_personal_tab_template()} + + % 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()} +</%def> + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if request.has_perm('people_profile.view_versions'): ThisPage.props.viewingHistory = Boolean @@ -3177,45 +3176,8 @@ }, } - </script> -</%def> -<%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"> + % if request.has_perm('people_profile.view_versions'): WholePageData.viewingHistory = false WholePageData.gettingRevisions = false @@ -3251,9 +3213,44 @@ }) } - </script> - % endif + % endif + </script> </%def> +<%def name="make_vue_components()"> + ${parent.make_vue_components()} -${parent.body()} + ${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> diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako index 274a8806..cb8b51aa 100644 --- a/tailbone/templates/poser/reports/view.mako +++ b/tailbone/templates/poser/reports/view.mako @@ -62,19 +62,13 @@ <br /> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('replace'): - <script type="text/javascript"> - - ${form.vue_component}Data.showUploadForm = false - - ${form.vue_component}Data.uploadFile = null - - ${form.vue_component}Data.uploadSubmitting = false - - </script> + <script> + ${form.vue_component}Data.showUploadForm = false + ${form.vue_component}Data.uploadFile = null + ${form.vue_component}Data.uploadSubmitting = false + </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako index 8d01bb33..239e7db2 100644 --- a/tailbone/templates/poser/setup.mako +++ b/tailbone/templates/poser/setup.mako @@ -118,14 +118,9 @@ % endif </%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.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 2ea289c8..ddc44e3d 100644 --- a/tailbone/templates/principal/find_by_perm.mako +++ b/tailbone/templates/principal/find_by_perm.mako @@ -10,8 +10,16 @@ </find-principals> </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%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()} <script type="text/x-template" id="find-principals-template"> <div> @@ -90,28 +98,6 @@ </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 = { @@ -240,12 +226,21 @@ } } - Vue.component('find-principals', FindPrincipals) - - <% request.register_component('find-principals', 'FindPrincipals') %> - </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> -${parent.body()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + <script> + Vue.component('find-principals', FindPrincipals) + <% request.register_component('find-principals', 'FindPrincipals') %> + </script> +</%def> diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index 66e38028..9f969468 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -60,9 +60,9 @@ </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> ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) @@ -114,6 +114,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako index 6121af67..a43a85d4 100644 --- a/tailbone/templates/products/configure.mako +++ b/tailbone/templates/products/configure.mako @@ -95,9 +95,9 @@ </div> </%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> ThisPage.methods.getTitleForKey = function(key) { switch (key) { @@ -118,6 +118,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako index b4731dee..5ffa9512 100644 --- a/tailbone/templates/products/index.mako +++ b/tailbone/templates/products/index.mako @@ -36,10 +36,10 @@ </${grid.component}> </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if label_profiles and master.has_perm('print_labels'): - <script type="text/javascript"> + <script> ${grid.vue_component}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n} ${grid.vue_component}Data.quickLabelQuantity = 1 @@ -83,6 +83,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako index 765c8838..72c9c76d 100644 --- a/tailbone/templates/products/pending/view.mako +++ b/tailbone/templates/products/pending/view.mako @@ -2,11 +2,6 @@ <%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()} @@ -67,9 +62,14 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%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> % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY): @@ -124,10 +124,7 @@ </script> </%def> -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} +<%def name="make_vue_components()"> + ${parent.make_vue_components()} ${product_lookup.tailbone_product_lookup_component()} </%def> - - -${parent.body()} diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako index bd4afc7f..66ca3128 100644 --- a/tailbone/templates/products/view.mako +++ b/tailbone/templates/products/view.mako @@ -282,9 +282,9 @@ % endif </%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.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n} ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n} @@ -411,6 +411,3 @@ % endif </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako index 0cfbc031..94028bdb 100644 --- a/tailbone/templates/purchases/credits/index.mako +++ b/tailbone/templates/purchases/credits/index.mako @@ -59,9 +59,9 @@ </%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> ${grid.vue_component}Data.changeStatusShowDialog = false ${grid.vue_component}Data.changeStatusOptions = ${json.dumps(status_options)|n} @@ -80,6 +80,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 45a8d66b..710dec4a 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -139,9 +139,15 @@ % endif </%def> -<%def name="render_this_page_template()"> - ${parent.render_this_page_template()} +<%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_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> @@ -162,16 +168,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="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> % if allow_confirm_all_costs: @@ -389,6 +388,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako index 5077539c..086754c6 100644 --- a/tailbone/templates/receiving/view_row.mako +++ b/tailbone/templates/receiving/view_row.mako @@ -484,9 +484,9 @@ </div> </%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> ## ThisPage.methods.editUnitCost = function() { ## alert("TODO: not yet implemented") @@ -720,6 +720,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako index a952fb6a..0921530c 100644 --- a/tailbone/templates/reports/generated/choose.mako +++ b/tailbone/templates/reports/generated/choose.mako @@ -53,13 +53,13 @@ % endif </%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> - TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n} + ${form.vue_component}Data.reportDescriptions = ${json.dumps(report_descriptions)|n} - TailboneForm.methods.reportTypeChanged = function(reportType) { + ${form.vue_component}.methods.reportTypeChanged = function(reportType) { this.$emit('report-change', this.reportDescriptions[reportType]) } @@ -71,6 +71,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako index bce54662..f60a9819 100644 --- a/tailbone/templates/reports/generated/delete.mako +++ b/tailbone/templates/reports/generated/delete.mako @@ -1,16 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/delete.mako" /> -<%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> % if params_data is not Undefined: ${form.vue_component}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 e5bcc9e4..cce6f346 100644 --- a/tailbone/templates/reports/generated/view.mako +++ b/tailbone/templates/reports/generated/view.mako @@ -23,16 +23,11 @@ % endif </%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> % if params_data is not Undefined: ${form.vue_component}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 f051959f..cc5adc10 100644 --- a/tailbone/templates/reports/inventory.mako +++ b/tailbone/templates/reports/inventory.mako @@ -48,15 +48,10 @@ ${h.end_form()} </%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.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 1e526792..61ccdb16 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_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.vendorUUID = null ThisPageData.departments = [] @@ -127,6 +127,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index 1d5cb14f..00ac1503 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -62,9 +62,9 @@ % endif </%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> % if weekdays_data is not Undefined: ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n} @@ -75,6 +75,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/roles/create.mako b/tailbone/templates/roles/create.mako index 625b2675..89dd56c3 100644 --- a/tailbone/templates/roles/create.mako +++ b/tailbone/templates/roles/create.mako @@ -6,15 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%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> // TODO: this variable name should be more dynamic (?) since this is // connected to (and only here b/c of) the permissions field - TailboneFormData.showingPermissionGroup = '' - + ${form.vue_component}Data.showingPermissionGroup = '' </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/roles/edit.mako b/tailbone/templates/roles/edit.mako index 67f63013..e77cca33 100644 --- a/tailbone/templates/roles/edit.mako +++ b/tailbone/templates/roles/edit.mako @@ -6,15 +6,11 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%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> // TODO: this variable name should be more dynamic (?) since this is // connected to (and only here b/c of) the permissions field - TailboneFormData.showingPermissionGroup = '' - + ${form.vue_component}Data.showingPermissionGroup = '' </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako index 0dc2956f..f5588695 100644 --- a/tailbone/templates/roles/view.mako +++ b/tailbone/templates/roles/view.mako @@ -6,9 +6,9 @@ ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))} </%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> % if users_data is not Undefined: ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n} @@ -23,5 +23,3 @@ </script> </%def> - -${parent.body()} diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako index ef487809..f9c815c2 100644 --- a/tailbone/templates/settings/email/configure.mako +++ b/tailbone/templates/settings/email/configure.mako @@ -86,9 +86,9 @@ </div> </%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.testRecipient = ${json.dumps(user_email_address)|n} ThisPageData.sendingTest = false @@ -137,6 +137,3 @@ % endif </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako index 050a5833..ab8d6fa4 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_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('configure'): - <script type="text/javascript"> + <script> ThisPageData.showEmails = 'available' @@ -65,5 +65,3 @@ </script> % endif </%def> - -${parent.body()} diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako index c1bc5ed4..73ad7066 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_this_page_template()"> - ${parent.render_this_page_template()} +<%def name="render_vue_templates()"> + ${parent.render_vue_templates()} <script type="text/x-template" id="email-preview-tools-template"> ${h.form(url('email.preview'), **{'@submit': 'submitPreviewForm'})} @@ -72,10 +72,6 @@ ${h.end_form()} </script> -</%def> - -<%def name="make_this_page_component()"> - ${parent.make_this_page_component()} <script type="text/javascript"> const EmailPreviewTools = { @@ -100,12 +96,13 @@ } } - Vue.component('email-preview-tools', EmailPreviewTools) - - <% request.register_component('email-preview-tools', 'EmailPreviewTools') %> - </script> </%def> - -${parent.body()} +<%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> diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako index 4fc2eb96..34844c5c 100644 --- a/tailbone/templates/tables/create.mako +++ b/tailbone/templates/tables/create.mako @@ -695,9 +695,9 @@ </b-steps> </%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> // nb. for warning user they may lose changes if leaving page ThisPageData.dirty = false @@ -983,6 +983,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako index 7dd9314a..a55af922 100644 --- a/tailbone/templates/tempmon/appliances/view.mako +++ b/tailbone/templates/tempmon/appliances/view.mako @@ -8,14 +8,9 @@ % endif </%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> ${form.vue_component}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 b1db423b..434da4c8 100644 --- a/tailbone/templates/tempmon/clients/view.mako +++ b/tailbone/templates/tempmon/clients/view.mako @@ -22,14 +22,9 @@ % endif </%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> ${form.vue_component}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 396b0e68..befaf8b4 100644 --- a/tailbone/templates/tempmon/dashboard.mako +++ b/tailbone/templates/tempmon/dashboard.mako @@ -59,9 +59,9 @@ % endif </%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.appliances = ${json.dumps(appliances_data)|n} ThisPageData.applianceUUID = ${json.dumps(appliance.uuid if appliance else None)|n} @@ -118,6 +118,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako index 412f25dd..94a440e0 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_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.currentTimeRange = ${json.dumps(current_time_range)|n} ThisPageData.chart = null @@ -128,6 +128,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 306b3430..14616474 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -20,38 +20,21 @@ </head> <body> - <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + <div id="app" style="height: 100%;"> <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.make_whole_page_app()} + ${self.render_vue_templates()} + ${self.modify_vue_vars()} + ${self.make_vue_components()} + ${self.make_vue_app()} </body> </html> @@ -596,7 +579,7 @@ </script> </%def> -<%def name="render_whole_page_template()"> +<%def name="render_vue_template_whole_page()"> <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;"> @@ -896,8 +879,6 @@ </footer> </div> </script> - -## ${multi_file_upload.render_template()} </%def> <%def name="render_this_page_component()"> @@ -1068,9 +1049,7 @@ % endif </%def> -<%def name="declare_whole_page_vars()"> -## ${multi_file_upload.declare_vars()} - +<%def name="render_vue_script_whole_page()"> <script> const WholePage = { @@ -1172,26 +1151,71 @@ </script> </%def> -<%def name="modify_whole_page_vars()"></%def> +############################## +## vue components + app +############################## -## TODO: do we really need this? -## <%def name="finalize_whole_page_vars()"></%def> +<%def name="render_vue_templates()"> +## ${multi_file_upload.render_template()} +## ${multi_file_upload.declare_vars()} -<%def name="make_whole_page_component()"> + ## 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()} -## ${self.finalize_whole_page_vars()} +</%def> +<%def name="make_vue_components()"> ${page_help.make_component()} -## ${multi_file_upload.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()"> <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' @@ -1223,3 +1247,11 @@ 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/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako index 4569759b..10c57e18 100644 --- a/tailbone/templates/trainwreck/transactions/configure.mako +++ b/tailbone/templates/trainwreck/transactions/configure.mako @@ -62,14 +62,9 @@ </div> </%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.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 b36e7bc3..f26515b5 100644 --- a/tailbone/templates/trainwreck/transactions/rollover.mako +++ b/tailbone/templates/trainwreck/transactions/rollover.mako @@ -48,14 +48,9 @@ </b-table> </%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.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 02950941..630950cf 100644 --- a/tailbone/templates/trainwreck/transactions/view.mako +++ b/tailbone/templates/trainwreck/transactions/view.mako @@ -1,15 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view.mako" /> -<%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> % if custorder_xref_markers_data is not Undefined: ${form.vue_component}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 9c76f7bd..2507492e 100644 --- a/tailbone/templates/trainwreck/transactions/view_row.mako +++ b/tailbone/templates/trainwreck/transactions/view_row.mako @@ -1,16 +1,11 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/view_row.mako" /> -<%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> % if discounts_data is not Undefined: ${form.vue_component}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 597cabfd..4815fc79 100644 --- a/tailbone/templates/units-of-measure/index.mako +++ b/tailbone/templates/units-of-measure/index.mako @@ -51,20 +51,17 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('collect_wild_uoms'): - <script type="text/javascript"> + <script> - TailboneGridData.showingCollectWildDialog = false + ${grid.vue_component}Data.showingCollectWildDialog = false - TailboneGrid.methods.collectFromWild = function() { - this.$refs['collect-wild-uoms-form'].submit() - } + ${grid.vue_component}.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 f7af685c..9439f830 100644 --- a/tailbone/templates/upgrades/configure.mako +++ b/tailbone/templates/upgrades/configure.mako @@ -111,9 +111,9 @@ </div> </%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.upgradeSystems = ${json.dumps(upgrade_systems)|n} ThisPageData.upgradeSystemShowDialog = false @@ -161,6 +161,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako index 6ae110e0..c3fca81d 100644 --- a/tailbone/templates/upgrades/view.mako +++ b/tailbone/templates/upgrades/view.mako @@ -137,11 +137,11 @@ % endif </%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> - TailboneFormData.showingPackages = 'diffs' + ${form.vue_component}Data.showingPackages = 'diffs' % if master.has_perm('execute'): @@ -153,7 +153,7 @@ // execute upgrade ////////////////////////////// - TailboneForm.props.upgradeExecuting = { + ${form.vue_component}.props.upgradeExecuting = { type: Boolean, default: false, } @@ -253,9 +253,9 @@ // execute upgrade ////////////////////////////// - TailboneFormData.formSubmitting = false + ${form.vue_component}Data.formSubmitting = false - TailboneForm.methods.submitForm = function() { + ${form.vue_component}.methods.submitForm = function() { this.formSubmitting = true } @@ -265,12 +265,12 @@ // declare failure ////////////////////////////// - TailboneForm.props.declareFailureSubmitting = { + ${form.vue_component}.props.declareFailureSubmitting = { type: Boolean, default: false, } - TailboneForm.methods.declareFailureClick = function() { + ${form.vue_component}.methods.declareFailureClick = function() { this.$emit('declare-failure-click') } @@ -287,6 +287,3 @@ </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako index c2e17396..ecfdd1c7 100644 --- a/tailbone/templates/users/preferences.mako +++ b/tailbone/templates/users/preferences.mako @@ -42,14 +42,9 @@ </div> </%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.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 06087927..d1afd218 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -76,10 +76,10 @@ % endif </%def> -<%def name="modify_this_page_vars()"> - ${parent.modify_this_page_vars()} +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} % if master.has_perm('manage_api_tokens'): - <script type="text/javascript"> + <script> ${form.vue_component}.props.apiTokens = null @@ -134,6 +134,3 @@ </script> % endif </%def> - - -${parent.body()} diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako index 79dad455..6b135346 100644 --- a/tailbone/templates/vendors/configure.mako +++ b/tailbone/templates/vendors/configure.mako @@ -44,14 +44,9 @@ </div> </%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.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 c5e22cfb..e902fd48 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_this_page_vars()"> - ${parent.modify_this_page_vars()} - <script type="text/javascript"> +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + <script> ThisPageData.activeStep = 'enter-details' @@ -334,6 +334,3 @@ def includeme(config): </script> </%def> - - -${parent.body()} diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako index 8740b4c9..432e011d 100644 --- a/tailbone/templates/workorders/view.mako +++ b/tailbone/templates/workorders/view.mako @@ -145,9 +145,9 @@ </nav> </%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.receiveButtonDisabled = false ThisPageData.receiveButtonText = "I've received the order from customer" @@ -216,6 +216,3 @@ </script> </%def> - - -${parent.body()} From 59bd58aca768f9e18a1e3db7447a576c48d29191 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 13:46:40 -0500 Subject: [PATCH 131/211] feat: add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy hoping to eventually replace the 'default' view with this one, if all goes well. definitely needs more testing and is not exposed as an option yet, unless configured --- tailbone/app.py | 3 +- tailbone/forms/core.py | 15 +- tailbone/grids/core.py | 14 +- tailbone/static/__init__.py | 5 +- tailbone/templates/appinfo/index.mako | 4 +- tailbone/templates/base.mako | 2 + tailbone/templates/batch/index.mako | 9 +- tailbone/templates/batch/view.mako | 20 +- tailbone/templates/form.mako | 5 +- tailbone/templates/themes/waterpark/base.mako | 486 ++++++++++++++++++ .../templates/themes/waterpark/configure.mako | 2 + tailbone/templates/themes/waterpark/form.mako | 2 + .../themes/waterpark/master/configure.mako | 2 + .../themes/waterpark/master/create.mako | 2 + .../themes/waterpark/master/delete.mako | 46 ++ .../themes/waterpark/master/edit.mako | 2 + .../themes/waterpark/master/form.mako | 2 + .../themes/waterpark/master/index.mako | 294 +++++++++++ .../themes/waterpark/master/view.mako | 2 + tailbone/templates/themes/waterpark/page.mako | 48 ++ tailbone/views/master.py | 12 +- tailbone/views/people.py | 2 +- tests/util.py | 2 +- 23 files changed, 937 insertions(+), 44 deletions(-) create mode 100644 tailbone/templates/themes/waterpark/base.mako create mode 100644 tailbone/templates/themes/waterpark/configure.mako create mode 100644 tailbone/templates/themes/waterpark/form.mako create mode 100644 tailbone/templates/themes/waterpark/master/configure.mako create mode 100644 tailbone/templates/themes/waterpark/master/create.mako create mode 100644 tailbone/templates/themes/waterpark/master/delete.mako create mode 100644 tailbone/templates/themes/waterpark/master/edit.mako create mode 100644 tailbone/templates/themes/waterpark/master/form.mako create mode 100644 tailbone/templates/themes/waterpark/master/index.mako create mode 100644 tailbone/templates/themes/waterpark/master/view.mako create mode 100644 tailbone/templates/themes/waterpark/page.mako diff --git a/tailbone/app.py b/tailbone/app.py index ad9663cf..b7262866 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -321,7 +321,8 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - settings.setdefault('mako.directories', ['tailbone:templates']) + settings.setdefault('mako.directories', ['tailbone:templates', + 'wuttaweb:templates']) rattail_config = make_rattail_config(settings) pyramid_config = make_pyramid_config(settings) pyramid_config.include('tailbone') diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 2f1c9370..059b212a 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -905,7 +905,8 @@ class Form(object): def render_vue_template(self, template='/forms/deform.mako', **context): """ """ - return self.render_deform(template=template, **context) + output = self.render_deform(template=template, **context) + return HTML.literal(output) def render_deform(self, dform=None, template=None, **kwargs): if not template: @@ -1220,6 +1221,18 @@ 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. diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 6ec55987..eada1041 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -216,39 +216,39 @@ class Grid(WuttaGrid): expose_direct_link=False, **kwargs, ): - if kwargs.get('component'): + 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')) - if kwargs.get('default_sortkey'): + if 'default_sortkey' in kwargs: warnings.warn("default_sortkey param is deprecated for Grid(); " "please use sort_defaults param instead", DeprecationWarning, stacklevel=2) - if kwargs.get('default_sortdir'): + if 'default_sortdir' in kwargs: warnings.warn("default_sortdir param is deprecated for Grid(); " "please use sort_defaults param instead", DeprecationWarning, stacklevel=2) - if kwargs.get('default_sortkey') or kwargs.get('default_sortdir'): + 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 kwargs.get('pageable'): + if 'pageable' in kwargs: warnings.warn("pageable param is deprecated for Grid(); " "please use vue_tagname param instead", DeprecationWarning, stacklevel=2) kwargs.setdefault('paginated', kwargs.pop('pageable')) - if kwargs.get('default_pagesize'): + 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 kwargs.get('default_page'): + if 'default_page' in kwargs: warnings.warn("default_page param is deprecated for Grid(); " "please use page param instead", DeprecationWarning, stacklevel=2) diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py index 2ad5161a..57700b80 100644 --- a/tailbone/static/__init__.py +++ b/tailbone/static/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,8 @@ Static Assets """ -from __future__ import unicode_literals, absolute_import - def includeme(config): + config.include('wuttaweb.static') config.add_static_view('tailbone', 'tailbone:static') config.add_static_view('deform', 'deform:static') diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 68244300..75032c1f 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8; -*- <%inherit file="/master/index.mako" /> -<%def name="render_grid_component()"> +<%def name="page_content()"> <div class="buttons"> @@ -108,7 +108,7 @@ <div class="panel-block"> <div style="width: 100%;"> - ${parent.render_grid_component()} + ${grid.render_vue_tag()} </div> </div> </${b}-collapse> diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index a0e58e22..eb950011 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -1,4 +1,5 @@ ## -*- 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" /> @@ -955,6 +956,7 @@ </%def> <%def name="make_vue_components()"> + ${make_wutta_components()} ${make_grid_filter_components()} ${page_help.make_component()} ${multi_file_upload.make_component()} diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako index a1b11b89..bea10a97 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.component} ref="executeResultsForm"></${execute_form.component}> + ${execute_form.render_vue_tag(ref='executeResultsForm')} </div> </div> </section> @@ -67,7 +67,7 @@ <%def name="render_vue_templates()"> ${parent.render_vue_templates()} % if master.results_executable and master.has_perm('execute_multiple'): - ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} % endif </%def> @@ -128,9 +128,6 @@ <%def name="make_vue_components()"> ${parent.make_vue_components()} % if master.results_executable and master.has_perm('execute_multiple'): - <script> - ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } - Vue.component('${execute_form.vue_tagname}', ${execute_form.vue_component}) - </script> + ${execute_form.render_vue_finalize()} % endif </%def> diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako index cdfa9ba7..7c81ab0e 100644 --- a/tailbone/templates/batch/view.mako +++ b/tailbone/templates/batch/view.mako @@ -119,8 +119,7 @@ <div class="markdown"> ${execution_described|n} </div> - <${execute_form.component} ref="executeBatchForm"> - </${execute_form.component}> + ${execute_form.render_vue_tag(ref='executeBatchForm')} </section> <footer class="modal-card-foot"> @@ -168,8 +167,7 @@ Please be certain to use the right one! </p> <br /> - <${upload_worksheet_form.component} ref="uploadForm"> - </${upload_worksheet_form.component}> + ${upload_worksheet_form.render_vue_tag(ref='uploadForm')} </section> <footer class="modal-card-foot"> @@ -254,10 +252,10 @@ <%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_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n} + ${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_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n} + ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)} % endif </%def> @@ -345,15 +343,9 @@ <%def name="make_vue_components()"> ${parent.make_vue_components()} % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'): - <script> - ${upload_worksheet_form.vue_component}.data = function() { return ${upload_worksheet_form.vue_component}Data } - Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.vue_component}) - </script> + ${upload_worksheet_form.render_vue_finalize()} % endif % if execute_enabled and master.has_perm('execute'): - <script> - ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data } - Vue.component('${execute_form.component}', ${execute_form.vue_component}) - </script> + ${execute_form.render_vue_finalize()} % endif </%def> diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index 3bb04257..e3a4d5dc 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -109,9 +109,6 @@ <%def name="make_vue_components()"> ${parent.make_vue_components()} % if form is not Undefined: - <script> - ${form.vue_component}.data = function() { return ${form.vue_component}Data } - Vue.component('${form.vue_tagname}', ${form.vue_component}) - </script> + ${form.render_vue_finalize()} % endif </%def> diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako new file mode 100644 index 00000000..15184f6e --- /dev/null +++ b/tailbone/templates/themes/waterpark/base.mako @@ -0,0 +1,486 @@ +## -*- 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()} + <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"> + ${base_meta.header_logo()} + <div id="global-header-title"> + ${base_meta.global_title()} + </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> + + % if request.has_perm('common.feedback'): + <feedback-form + action="${url('feedback')}" + :message="feedbackMessage"> + </feedback-form> + % 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_templates()"> + ${parent.render_vue_templates()} + + ${page_help.render_template()} + ${page_help.declare_vars()} + + % if request.has_perm('common.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.trim()"> + {{ sendingFeedback ? "Working, please wait..." : "Send Message" }} + </b-button> + </footer> + </div> + </b-modal> + + </div> + </script> + <script> + + const FeedbackForm = { + template: '#feedback-template', + mixins: [SimpleRequestMixin], + props: [ + 'action', + 'message', + ], + methods: { + + showFeedback() { + this.referrer = location.href + this.showDialog = true + this.$nextTick(function() { + this.$refs.textarea.focus() + }) + }, + + % if config.get_bool('tailbone.feedback_allows_reply'): + pleaseReplyChanged(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + }, + % endif + + sendFeedback() { + this.sendingFeedback = true + + const params = { + referrer: this.referrer, + user: this.userUUID, + user_name: this.userName, + % if config.get_bool('tailbone.feedback_allows_reply'): + please_reply_to: this.pleaseReply ? this.userEmail : null, + % endif + message: this.message.trim(), + } + + this.simplePOST(this.action, params, response => { + + this.$buefy.toast.open({ + message: "Message sent! Thank you for your feedback.", + type: 'is-info', + duration: 4000, // 4 seconds + }) + + this.showDialog = false + // clear out message, in case they need to send another + this.message = "" + this.sendingFeedback = false + + }, response => { // failure + this.sendingFeedback = false + }) + }, + } + } + + const FeedbackFormData = { + referrer: null, + userUUID: null, + userName: null, + userEmail: null, + % if config.get_bool('tailbone.feedback_allows_reply'): + pleaseReply: false, + % endif + showDialog: false, + sendingFeedback: false, + } + + </script> + % endif +</%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 + + ############################## + ## feedback + ############################## + + % if request.has_perm('common.feedback'): + + WholePageData.feedbackMessage = "" + + % if request.user: + FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} + FeedbackFormData.userName = ${json.dumps(str(request.user))|n} + % endif + + % endif + + ############################## + ## edit fields help + ############################## + + % 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()} + % if request.has_perm('common.feedback'): + <script> + FeedbackForm.data = function() { return FeedbackFormData } + Vue.component('feedback-form', FeedbackForm) + </script> + % endif +</%def> diff --git a/tailbone/templates/themes/waterpark/configure.mako b/tailbone/templates/themes/waterpark/configure.mako new file mode 100644 index 00000000..9ac9a5cd --- /dev/null +++ b/tailbone/templates/themes/waterpark/configure.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/configure.mako" /> diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako new file mode 100644 index 00000000..cf1ddb8a --- /dev/null +++ b/tailbone/templates/themes/waterpark/form.mako @@ -0,0 +1,2 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/form.mako" /> diff --git a/tailbone/templates/themes/waterpark/master/configure.mako b/tailbone/templates/themes/waterpark/master/configure.mako new file mode 100644 index 00000000..51da5b0a --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/configure.mako @@ -0,0 +1,2 @@ +## -*- 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 new file mode 100644 index 00000000..23399b9e --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/create.mako @@ -0,0 +1,2 @@ +## -*- 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 new file mode 100644 index 00000000..a15dfaf8 --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/delete.mako @@ -0,0 +1,46 @@ +## -*- 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 new file mode 100644 index 00000000..18a2fa2f --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/edit.mako @@ -0,0 +1,2 @@ +## -*- 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 new file mode 100644 index 00000000..db56843b --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/form.mako @@ -0,0 +1,2 @@ +## -*- 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 new file mode 100644 index 00000000..e3b5b42d --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/index.mako @@ -0,0 +1,294 @@ +## -*- 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> + +<%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 new file mode 100644 index 00000000..99194469 --- /dev/null +++ b/tailbone/templates/themes/waterpark/master/view.mako @@ -0,0 +1,2 @@ +## -*- 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 new file mode 100644 index 00000000..7e6851a7 --- /dev/null +++ b/tailbone/templates/themes/waterpark/page.mako @@ -0,0 +1,48 @@ +## -*- 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(request.session.get_csrf_token() or request.session.new_csrf_token())|n} + + % if can_edit_help: + ThisPage.props.configureFieldsHelp = Boolean + % endif + + </script> +</%def> diff --git a/tailbone/views/master.py b/tailbone/views/master.py index ac74a070..a8365482 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -137,6 +137,7 @@ class MasterView(View): deleting = False executing = False cloning = False + configuring = False has_pk_fields = False has_image = False has_thumbnail = False @@ -350,6 +351,7 @@ class MasterView(View): return self.json_response(context) context = { + 'index_url': None, # nb. avoid title link since this *is* the index 'grid': grid, } @@ -380,7 +382,7 @@ class MasterView(View): grid contents etc. """ - def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): + def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs): """ Creates a new grid instance """ @@ -389,7 +391,7 @@ class MasterView(View): if key is None: key = self.get_grid_key() if data is None: - data = self.get_data(session=kwargs.get('session')) + data = self.get_data(session=session) if columns is None: columns = self.get_grid_columns() @@ -407,7 +409,7 @@ class MasterView(View): """ if session is None: session = self.Session() - kwargs.setdefault('pageable', False) + kwargs.setdefault('paginated', False) grid = self.make_grid(session=session, **kwargs) return grid.make_visible_data() @@ -1701,7 +1703,7 @@ class MasterView(View): """ if session is None: session = self.Session() - kwargs.setdefault('pageable', False) + kwargs.setdefault('paginated', False) kwargs.setdefault('sortable', sort) grid = self.make_row_grid(session=session, **kwargs) return grid.make_visible_data() @@ -1879,6 +1881,7 @@ 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': @@ -5119,6 +5122,7 @@ 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'): diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 020babc5..b6a4c0b9 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -543,7 +543,7 @@ class PersonView(MasterView): }, filterable=True, sortable=True, - pageable=True, + paginated=True, default_sortkey='end_time', default_sortdir='desc', component='transactions-grid', diff --git a/tests/util.py b/tests/util.py index 3aa04f5e..4277a7c3 100644 --- a/tests/util.py +++ b/tests/util.py @@ -24,7 +24,7 @@ class WebTestCase(DataTestCase): self.pyramid_config = testing.setUp(request=self.request, settings={ 'wutta_config': self.config, 'rattail_config': self.config, - 'mako.directories': ['tailbone:templates'], + 'mako.directories': ['tailbone:templates', 'wuttaweb:templates'], # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', }) From 83586ef90fd3c8acae6eda85bd7d44a5992464f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 15:06:09 -0500 Subject: [PATCH 132/211] =?UTF-8?q?bump:=20version=200.19.3=20=E2=86=92=20?= =?UTF-8?q?0.20.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8017445..5840f59f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index 3e07abaa..150544ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.19.3" +version = "0.20.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.10.2", + "WuttaWeb>=0.11.0", "zope.sqlalchemy>=1.5", ] From 21f90f3f32f76d509b75348388445cc1a6dccd85 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 16:02:35 -0500 Subject: [PATCH 133/211] fix: fix default filter verbs logic for workorder status --- tailbone/views/workorders.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tailbone/views/workorders.py b/tailbone/views/workorders.py index a53037bc..d8094e4b 100644 --- a/tailbone/views/workorders.py +++ b/tailbone/views/workorders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -83,12 +83,12 @@ class WorkOrderView(MasterView): ] def __init__(self, request): - super(WorkOrderView, self).__init__(request) + super().__init__(request) app = self.get_rattail_app() self.workorder_handler = app.get_workorder_handler() def configure_grid(self, g): - super(WorkOrderView, self).configure_grid(g) + super().configure_grid(g) model = self.model # customer @@ -113,7 +113,7 @@ class WorkOrderView(MasterView): return 'warning' def configure_form(self, f): - super(WorkOrderView, self).configure_form(f) + super().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(WorkOrderView, self).configure_row_grid(g) + super().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(StatusFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) from drild import enum @@ -369,14 +369,14 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter): @property def verb_labels(self): - labels = dict(super(StatusFilter, self).verb_labels) + labels = dict(super().verb_labels) labels['is_active'] = "Is Active" labels['not_active'] = "Is Not Active" return labels @property def valueless_verbs(self): - verbs = list(super(StatusFilter, self).valueless_verbs) + verbs = list(super().valueless_verbs) verbs.extend([ 'is_active', 'not_active', @@ -385,7 +385,11 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter): @property def default_verbs(self): - verbs = list(super(StatusFilter, self).default_verbs) + verbs = super().default_verbs + if callable(verbs): + verbs = verbs() + + verbs = list(verbs or []) verbs.insert(0, 'is_active') verbs.insert(1, 'not_active') return verbs From 526c84dfa62cc88d2cd4ec28861e6caef70205e4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 16:05:52 -0500 Subject: [PATCH 134/211] =?UTF-8?q?bump:=20version=200.20.0=20=E2=86=92=20?= =?UTF-8?q?0.20.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5840f59f..4e2b348a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.20.1 (2024-08-20) + +### Fix + +- fix default filter verbs logic for workorder status + ## v0.20.0 (2024-08-20) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 150544ba..90ecd953 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.20.0" +version = "0.20.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From c8dc60cb68c72530b04df13fdc012a3ba382ba01 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 16:37:58 -0500 Subject: [PATCH 135/211] fix: fix spacing for navbar logo/title in waterpark theme --- tailbone/templates/themes/waterpark/base.mako | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako index 15184f6e..878090dc 100644 --- a/tailbone/templates/themes/waterpark/base.mako +++ b/tailbone/templates/themes/waterpark/base.mako @@ -50,9 +50,11 @@ <div class="navbar-brand"> <a class="navbar-item" href="${url('home')}" v-show="!menuSearchActive"> - ${base_meta.header_logo()} - <div id="global-header-title"> - ${base_meta.global_title()} + <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" From 07871188aa323331a4464c80021b4f25057dd54d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 17:03:57 -0500 Subject: [PATCH 136/211] fix: fix master/index template rendering for waterpark theme --- tailbone/templates/themes/waterpark/master/index.mako | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/templates/themes/waterpark/master/index.mako b/tailbone/templates/themes/waterpark/master/index.mako index e3b5b42d..e6702599 100644 --- a/tailbone/templates/themes/waterpark/master/index.mako +++ b/tailbone/templates/themes/waterpark/master/index.mako @@ -254,6 +254,11 @@ </%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> From 1def26a35bc36b399ff6783198a4687af206482e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 19:09:56 -0500 Subject: [PATCH 137/211] feat: add "has output file templates" config option for master view this is a bit hacky, a quick copy/paste job from the equivalent feature for input file templates. i assume this will get cleaned up when moved to wuttaweb.. --- tailbone/templates/configure.mako | 107 +++++++++- .../templates/themes/waterpark/configure.mako | 76 +++++++ tailbone/views/master.py | 202 +++++++++++++++++- 3 files changed, 381 insertions(+), 4 deletions(-) diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 272aadce..6d9c2261 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -143,6 +143,68 @@ </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="null">-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']}']"> + + <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> + + </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()"> @@ -229,6 +291,7 @@ ThisPageData.settingsNeedSaved = false ThisPageData.undoChanges = false ThisPageData.savingSettings = false + ThisPageData.validators = [] ThisPage.methods.purgeSettingsInit = function() { this.purgeSettingsShowDialog = true @@ -260,7 +323,19 @@ } ThisPage.methods.saveSettings = function() { - let msg = this.validateSettings() + let msg + + // nb. this is the future + for (let validator of this.validators) { + msg = validator.call(this) + if (msg) { + alert(msg) + return + } + } + + // nb. legacy method + msg = this.validateSettings() if (msg) { alert(msg) return @@ -291,5 +366,35 @@ window.addEventListener('beforeunload', this.beforeWindowUnload) } + ############################## + ## 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/configure.mako b/tailbone/templates/themes/waterpark/configure.mako index 9ac9a5cd..7a3e5261 100644 --- a/tailbone/templates/themes/waterpark/configure.mako +++ b/tailbone/templates/themes/waterpark/configure.mako @@ -1,2 +1,78 @@ ## -*- 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/views/master.py b/tailbone/views/master.py index a8365482..e4d6c3f6 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -117,6 +117,7 @@ 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 @@ -1820,6 +1821,26 @@ 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. @@ -2848,6 +2869,12 @@ 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): @@ -2922,6 +2949,81 @@ 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. @@ -2969,6 +3071,12 @@ 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. @@ -5204,6 +5312,39 @@ 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 @@ -5248,7 +5389,8 @@ class MasterView(View): simple['option']) def configure_get_context(self, simple_settings=None, - input_file_templates=True): + input_file_templates=True, + output_file_templates=True): """ Returns the full context dict, for rendering the configure page template. @@ -5305,10 +5447,27 @@ 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'] + 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): + input_file_templates=True, + output_file_templates=True): settings = [] # maybe collect "simple" settings @@ -5354,10 +5513,30 @@ 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): + input_file_templates=True, + output_file_templates=True): app = self.get_rattail_app() model = self.model names = [] @@ -5376,6 +5555,14 @@ 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 @@ -5638,6 +5825,15 @@ 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) From b6a8e508bf2629d528b1bba3e1b12d6da83b1abf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 22:16:01 -0500 Subject: [PATCH 138/211] fix: prefer wuttaweb config for "home redirect to login" feature --- tailbone/views/common.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 7e9ddb09..26ef2626 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -25,6 +25,7 @@ Various common views """ import os +import warnings from collections import OrderedDict from rattail.batch import consume_batch_id @@ -50,9 +51,21 @@ class CommonView(View): Home page view. """ app = self.get_rattail_app() + + # maybe auto-redirect anons to login if not self.request.user: - if self.rattail_config.getbool('tailbone', 'login_is_home', default=True): - raise self.redirect(self.request.route_url('login')) + redirect = self.config.get_bool('wuttaweb.home_redirect_to_login') + if redirect is None: + redirect = self.config.get_bool('tailbone.login_is_home') + if redirect is not None: + warnings.warn("tailbone.login_is_home setting is deprecated; " + "please set wuttaweb.home_redirect_to_login instead", + DeprecationWarning) + else: + # TODO: this is opposite of upstream default, should change + redirect = True + if redirect: + return self.redirect(self.request.route_url('login')) image_url = self.rattail_config.get( 'tailbone', 'main_image_url', From 2ffc067097a7c979c4935eee1da4d697e7774845 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 22:27:11 -0500 Subject: [PATCH 139/211] fix: inherit from wuttaweb for appinfo/index template although for now, still must override for some link buttons --- tailbone/templates/appinfo/index.mako | 95 +------------------------- tailbone/templates/grids/complete.mako | 14 ++++ tailbone/views/settings.py | 10 +++ 3 files changed, 26 insertions(+), 93 deletions(-) diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index 75032c1f..faaea935 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -1,8 +1,7 @@ ## -*- coding: utf-8; -*- -<%inherit file="/master/index.mako" /> +<%inherit file="wuttaweb:templates/appinfo/index.mako" /> <%def name="page_content()"> - <div class="buttons"> <once-button type="is-primary" @@ -28,95 +27,5 @@ </div> - <${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%;"> - ${grid.render_vue_tag()} - </div> - </div> - </${b}-collapse> -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n} - </script> + ${parent.page_content()} </%def> diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index c136273b..5d406512 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -257,6 +257,9 @@ loading: false, ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n}, + ## nb. this tracks whether grid.fetchFirstData() happened + fetchedFirstData: false, + savingDefaults: false, data: ${grid.vue_component}CurrentData, @@ -519,6 +522,17 @@ ...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 diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index bda62ccc..4d99cb2a 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -71,10 +71,20 @@ class AppInfoView(MasterView): app.get_title()) def get_data(self, session=None): + """ """ + + # nb. init with empty data, only load it upon user request + if not self.request.GET.get('partial'): + return [] + + # TODO: pretty sure this is not cross-platform. probably some + # sort of pip methods belong on the app handler? or it should + # have a pip handler for all that? pip = os.path.join(sys.prefix, 'bin', 'pip') output = subprocess.check_output([pip, 'list', '--format=json']) data = json.loads(output.decode('utf_8').strip()) + # must avoid null values for sort to work right for pkg in data: pkg.setdefault('editable_project_location', '') From f7554602420eceb62d98fbde600c86aba0a944a3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 20 Aug 2024 23:23:23 -0500 Subject: [PATCH 140/211] feat: inherit from wuttaweb for AppInfoView, appinfo/configure template --- tailbone/menus.py | 2 +- tailbone/templates/appinfo/configure.mako | 247 +----------------- .../themes/butterball/buefy-components.mako | 9 + tailbone/views/settings.py | 202 +++----------- 4 files changed, 48 insertions(+), 412 deletions(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index abd0b58b..3ddee095 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -703,7 +703,7 @@ class TailboneMenuHandler(WuttaMenuHandler): }, {'type': 'sep'}, { - 'title': "App Details", + 'title': "App Info", 'route': 'appinfo', 'perm': 'appinfo.list', }, diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 4794f00b..9d866cea 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -1,247 +1,2 @@ ## -*- coding: utf-8; -*- -<%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('wuttaweb.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.libver.{}']".format(weblib['key'])})} - ${h.hidden('wuttaweb.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.liburl.{}']".format(weblib['key'])})} - % endfor - - <${b}-modal has-modal-card - % if request.use_oruga: - v-model:active="editWebLibraryShowDialog" - % else: - :active.sync="editWebLibraryShowDialog" - % endif - > - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">Web Library: {{ editWebLibraryRecord.title }}</p> - </header> - - <section class="modal-card-body"> - - <b-field grouped> - - <b-field label="Default Version"> - <b-input v-model="editWebLibraryRecord.default_version" - disabled> - </b-input> - </b-field> - - <b-field label="Override Version"> - <b-input v-model="editWebLibraryVersion"> - </b-input> - </b-field> - - </b-field> - - <b-field label="Override URL"> - <b-input v-model="editWebLibraryURL" - expanded /> - </b-field> - - <b-field label="Effective URL (as of last page load)"> - <b-input v-model="editWebLibraryRecord.live_url" - disabled - expanded /> - </b-field> - - </section> - - <footer class="modal-card-foot"> - <b-button type="is-primary" - @click="editWebLibrarySave()" - icon-pack="fas" - icon-left="save"> - Save - </b-button> - <b-button @click="editWebLibraryShowDialog = false"> - Cancel - </b-button> - </footer> - </div> - </${b}-modal> - - </div> -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - - ThisPageData.weblibs = ${json.dumps(weblibs)|n} - - ThisPageData.editWebLibraryShowDialog = false - ThisPageData.editWebLibraryRecord = {} - ThisPageData.editWebLibraryVersion = null - ThisPageData.editWebLibraryURL = null - - ThisPage.methods.editWebLibraryInit = function(row) { - this.editWebLibraryRecord = row - this.editWebLibraryVersion = row.configured_version - this.editWebLibraryURL = row.configured_url - this.editWebLibraryShowDialog = true - } - - ThisPage.methods.editWebLibrarySave = function() { - this.editWebLibraryRecord.configured_version = this.editWebLibraryVersion - this.editWebLibraryRecord.configured_url = this.editWebLibraryURL - this.editWebLibraryRecord.modified = true - - this.simpleSettings[`wuttaweb.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion - this.simpleSettings[`wuttaweb.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL - - this.settingsNeedSaved = true - this.editWebLibraryShowDialog = false - } - - </script> -</%def> +<%inherit file="wuttaweb:templates/appinfo/configure.mako" /> diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako index 51a0deb9..3a2cd798 100644 --- a/tailbone/templates/themes/butterball/buefy-components.mako +++ b/tailbone/templates/themes/butterball/buefy-components.mako @@ -666,6 +666,7 @@ <%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> @@ -676,6 +677,14 @@ props: { label: String, multilined: Boolean, + position: String, + }, + computed: { + orugaPosition() { + if (this.position) { + return this.position.replace(/^is-/, '') + } + }, }, } </script> diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 4d99cb2a..099a77e1 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -25,11 +25,7 @@ Settings Views """ import json -import os import re -import subprocess -import sys -from collections import OrderedDict import colander @@ -37,201 +33,77 @@ from rattail.db.model import Setting from rattail.settings import Setting as AppSetting from rattail.util import import_module_path -from tailbone import forms +from tailbone import forms, grids 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 -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 +class AppInfoView(WuttaAppInfoView): + """ """ + Session = Session + weblib_config_prefix = 'tailbone' - grid_columns = [ - 'name', - 'version', - 'editable_project_location', - ] - - def get_index_title(self): - app = self.get_rattail_app() - return "{} for {}".format(self.get_model_title_plural(), - app.get_title()) - - def get_data(self, session=None): + # TODO: for now we override to get tailbone searchable grid + def make_grid(self, **kwargs): """ """ - - # nb. init with empty data, only load it upon user request - if not self.request.GET.get('partial'): - return [] - - # TODO: pretty sure this is not cross-platform. probably some - # sort of pip methods belong on the app handler? or it should - # have a pip handler for all that? - pip = os.path.join(sys.prefix, 'bin', 'pip') - output = subprocess.check_output([pip, 'list', '--format=json']) - data = json.loads(output.decode('utf_8').strip()) - - # must avoid null values for sort to work right - for pkg in data: - pkg.setdefault('editable_project_location', '') - - return data + return grids.Grid(self.request, **kwargs) def configure_grid(self, g): + """ """ super().configure_grid(g) - # sort on frontend - g.sort_on_backend = False - g.sort_multiple = False - g.set_sort_defaults('name') - # name g.set_searchable('name') # editable_project_location g.set_searchable('editable_project_location') - def template_kwargs_index(self, **kwargs): - kwargs = super().template_kwargs_index(**kwargs) - kwargs['configure_button_title'] = "Configure App" - return kwargs - - def get_weblibs(self): - """ """ - return OrderedDict([ - ('vue', "Vue"), - ('vue_resource', "vue-resource"), - ('buefy', "Buefy"), - ('buefy.css', "Buefy CSS"), - ('fontawesome', "FontAwesome"), - ('bb_vue', "(BB) vue"), - ('bb_oruga', "(BB) @oruga-ui/oruga-next"), - ('bb_oruga_bulma', "(BB) @oruga-ui/theme-bulma (JS)"), - ('bb_oruga_bulma_css', "(BB) @oruga-ui/theme-bulma (CSS)"), - ('bb_fontawesome_svg_core', "(BB) @fortawesome/fontawesome-svg-core"), - ('bb_free_solid_svg_icons', "(BB) @fortawesome/free-solid-svg-icons"), - ('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"), - ]) - def configure_get_context(self, **kwargs): """ """ context = super().configure_get_context(**kwargs) simple_settings = context['simple_settings'] - weblibs = self.get_weblibs() + weblibs = context['weblibs'] - for key in weblibs: - title = weblibs[key] - weblibs[key] = { - 'key': key, - 'title': title, - - # nb. these values are exactly as configured, and are - # used for editing the settings - 'configured_version': get_libver(self.request, key, - prefix='tailbone', - configured_only=True), - 'configured_url': get_liburl(self.request, key, - prefix='tailbone', - configured_only=True), - - # these are for informational purposes only - 'default_version': get_libver(self.request, key, - prefix='tailbone', - default_only=True), - 'live_url': get_liburl(self.request, key, - prefix='tailbone'), - } + for weblib in weblibs: + key = weblib['key'] # TODO: this is only needed to migrate legacy settings to - # use the newer wutaweb setting names + # use the newer wuttaweb setting names url = simple_settings[f'wuttaweb.liburl.{key}'] - if not url and weblibs[key]['configured_url']: - simple_settings[f'wuttaweb.liburl.{key}'] = weblibs[key]['configured_url'] + if not url and weblib['configured_url']: + simple_settings[f'wuttaweb.liburl.{key}'] = weblib['configured_url'] - context['weblibs'] = list(weblibs.values()) return context def configure_get_simple_settings(self): """ """ - simple_settings = [ + simple_settings = super().configure_get_simple_settings() - # 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'}, + # TODO: the update home page redirect setting is off by + # default for wuttaweb, but on for tailbone + for setting in simple_settings: + 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['default'] = value + break - # display - {'section': 'tailbone', - 'option': 'background_color'}, + # nb. these are no longer used (deprecated), but we keep + # them defined here so the tool auto-deletes them - # grids - {'section': 'tailbone', - 'option': 'grid.default_pagesize', - # TODO: seems like should enforce this, but validation is - # not setup yet - # 'type': int - }, + simple_settings.extend([ + {'name': 'tailbone.buefy_version'}, + {'name': 'tailbone.vue_version'}, + ]) - # 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'}, - - ] - - def getval(key): - return self.config.get(f'tailbone.{key}') - - weblibs = self.get_weblibs() - for key, title in weblibs.items(): - - simple_settings.append({ - 'section': 'wuttaweb', - 'option': f"libver.{key}", - 'default': getval(f"libver.{key}"), - }) - simple_settings.append({ - 'section': 'wuttaweb', - 'option': f"liburl.{key}", - 'default': getval(f"liburl.{key}"), - }) - - # nb. these are no longer used (deprecated), but we keep - # them defined here so the tool auto-deletes them - simple_settings.append({ - 'section': 'tailbone', - 'option': f"libver.{key}", - }) - simple_settings.append({ - 'section': 'tailbone', - 'option': f"liburl.{key}", - }) + for key in self.get_weblibs(): + simple_settings.extend([ + {'name': f'tailbone.libver.{key}'}, + {'name': f'tailbone.liburl.{key}'}, + ]) return simple_settings From 71abbe06da0d08c4a285fbca2b583c570f3def4c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 21 Aug 2024 00:07:03 -0500 Subject: [PATCH 141/211] feat: inherit from wuttaweb templates for home, login pages --- tailbone/templates/base_meta.mako | 13 +----- tailbone/templates/home.mako | 30 +----------- tailbone/templates/login.mako | 77 ++----------------------------- tailbone/views/common.py | 12 +++-- 4 files changed, 18 insertions(+), 114 deletions(-) diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index 00cfdfe9..b6376448 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -1,10 +1,7 @@ ## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/base_meta.mako" /> -<%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="app_title()">${app.get_node_title()}</%def> <%def name="favicon()"> <link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" /> @@ -13,9 +10,3 @@ <%def name="header_logo()"> ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")} </%def> - -<%def name="footer()"> - <p class="has-text-centered"> - powered by ${h.link_to("Rattail", url('about'))} - </p> -</%def> diff --git a/tailbone/templates/home.mako b/tailbone/templates/home.mako index e4f7d072..54e44d57 100644 --- a/tailbone/templates/home.mako +++ b/tailbone/templates/home.mako @@ -1,33 +1,7 @@ ## -*- coding: utf-8; -*- -<%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> +<%inherit file="wuttaweb:templates/home.mako" /> +## 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/login.mako b/tailbone/templates/login.mako index 3eb46403..d2ea7828 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -1,84 +1,17 @@ ## -*- coding: utf-8; -*- -<%inherit file="/form.mako" /> -<%namespace name="base_meta" file="/base_meta.mako" /> - -<%def name="title()">Login</%def> +<%inherit file="wuttaweb:templates/auth/login.mako" /> +## TODO: this will not be needed with wuttaform <%def name="extra_styles()"> ${parent.extra_styles()} - <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 { + <style> + .card-content .buttons { justify-content: right; } </style> </%def> -<%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> - +## DEPRECATED; remains for back-compat <%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_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - - ${form.vue_component}Data.usernameInput = null - - ${form.vue_component}.mounted = function() { - this.$refs.username.focus() - this.usernameInput = this.$refs.username.$el.querySelector('input') - this.usernameInput.addEventListener('keydown', this.usernameKeydown) - } - - ${form.vue_component}.beforeDestroy = function() { - this.usernameInput.removeEventListener('keydown', this.usernameKeydown) - } - - ${form.vue_component}.methods.usernameKeydown = function(event) { - if (event.which == 13) { - event.preventDefault() - this.$refs.password.focus() - } - } - - </script> -</%def> diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 26ef2626..f4d98c05 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -67,9 +67,15 @@ class CommonView(View): if redirect: return self.redirect(self.request.route_url('login')) - image_url = self.rattail_config.get( - 'tailbone', 'main_image_url', - default=self.request.static_url('tailbone:static/img/home_logo.png')) + image_url = self.config.get('wuttaweb.logo_url') + if not image_url: + image_url = self.config.get('tailbone.main_image_url') + if image_url: + warnings.warn("tailbone.main_image_url setting is deprecated; " + "please set wuttaweb.logo_url instead", + DeprecationWarning) + else: + image_url = self.request.static_url('tailbone:static/img/home_logo.png') context = { 'image_url': image_url, From 1d00fe994a069e366d67558d4f5f3709e103e991 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 21 Aug 2024 09:44:32 -0500 Subject: [PATCH 142/211] fix: use wuttaweb to get/render csrf token --- tailbone/helpers.py | 12 ++++----- tailbone/templates/formposter.mako | 2 +- tailbone/templates/forms/deform.mako | 2 +- tailbone/templates/ordering/view.mako | 2 +- tailbone/templates/ordering/worksheet.mako | 2 +- tailbone/templates/page.mako | 2 +- tailbone/templates/themes/waterpark/page.mako | 2 +- tailbone/util.py | 27 +++++++++---------- 8 files changed, 24 insertions(+), 27 deletions(-) diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 23988423..50b38c30 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -24,6 +24,9 @@ Template Context Helpers """ +# start off with all from wuttaweb +from wuttaweb.helpers import * + import os import datetime from decimal import Decimal @@ -33,12 +36,7 @@ from rattail.time import localtime, make_utc from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal from rattail.db.util import maxlen -from webhelpers2.html import * -from webhelpers2.html.tags import * - -from wuttaweb.util import get_liburl -from tailbone.util import (csrf_token, get_csrf_token, - pretty_datetime, raw_datetime, +from tailbone.util import (pretty_datetime, raw_datetime, render_markdown, route_exists) diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako index ab9c720d..d566a467 100644 --- a/tailbone/templates/formposter.mako +++ b/tailbone/templates/formposter.mako @@ -39,7 +39,7 @@ simplePOST(action, params, success, failure) { - let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} + let csrftoken = ${json.dumps(h.get_csrf_token(request))|n} let headers = { '${csrf_header_name}': csrftoken, diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 26c8b4ee..ea35ab17 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -180,7 +180,7 @@ let ${form.vue_component}Data = { ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, % if can_edit_help: fieldLabels: ${json.dumps(field_labels)|n}, diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako index 584559c1..34a6085f 100644 --- a/tailbone/templates/ordering/view.mako +++ b/tailbone/templates/ordering/view.mako @@ -204,7 +204,7 @@ saving: false, ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } }, computed: { diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako index cb98c48f..eb2077e7 100644 --- a/tailbone/templates/ordering/worksheet.mako +++ b/tailbone/templates/ordering/worksheet.mako @@ -250,7 +250,7 @@ submitting: false, ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } }, methods: { diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 54b47278..43b0a266 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -38,7 +38,7 @@ const ThisPageData = { ## TODO: should find a better way to handle CSRF token - csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, + csrftoken: ${json.dumps(h.get_csrf_token(request))|n}, } </script> diff --git a/tailbone/templates/themes/waterpark/page.mako b/tailbone/templates/themes/waterpark/page.mako index 7e6851a7..66ce47dc 100644 --- a/tailbone/templates/themes/waterpark/page.mako +++ b/tailbone/templates/themes/waterpark/page.mako @@ -38,7 +38,7 @@ ${parent.modify_vue_vars()} <script> - ThisPageData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n} + ThisPageData.csrftoken = ${json.dumps(h.get_csrf_token(request))|n} % if can_edit_help: ThisPage.props.configureFieldsHelp = Boolean diff --git a/tailbone/util.py b/tailbone/util.py index 594fd69b..71aa35e3 100644 --- a/tailbone/util.py +++ b/tailbone/util.py @@ -41,7 +41,9 @@ 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_liburl as wutta_get_liburl, + get_csrf_token as wutta_get_csrf_token, + render_csrf_token) log = logging.getLogger(__name__) @@ -59,22 +61,19 @@ class SortColumn(object): def 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 + """ """ + 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) def csrf_token(request, name='_csrf'): - """ - 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;") + """ """ + 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) def get_form_data(request): From ffa724ef374ec59e90b51a2b14a83ee703bea5a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 21 Aug 2024 15:50:55 -0500 Subject: [PATCH 143/211] fix: move "searchable columns" grid feature to wuttaweb --- tailbone/grids/core.py | 19 +++++++------------ tailbone/templates/grids/complete.mako | 6 ++---- tests/grids/test_core.py | 6 ++++++ 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index eada1041..92452b31 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -200,7 +200,6 @@ class Grid(WuttaGrid): filterable=False, filters={}, use_byte_string_filters=False, - searchable={}, checkboxes=False, checked=None, check_handler=None, @@ -254,6 +253,12 @@ class Grid(WuttaGrid): 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') @@ -287,8 +292,6 @@ class Grid(WuttaGrid): self.use_byte_string_filters = use_byte_string_filters self.filters = self.make_filters(filters) - self.searchable = searchable or {} - self.checkboxes = checkboxes self.checked = checked if self.checked is None: @@ -481,15 +484,6 @@ class Grid(WuttaGrid): 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) @@ -1587,6 +1581,7 @@ class Grid(WuttaGrid): 'field': name, 'label': self.get_label(name), 'sortable': self.is_sortable(name), + 'searchable': self.is_searchable(name), 'visible': name not in self.invisible, }) return columns diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 5d406512..54ad0527 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -136,10 +136,8 @@ <${b}-table-column field="${column['field']}" label="${column['label']}" v-slot="props" - :sortable="${json.dumps(column.get('sortable', False))}" - % if hasattr(grid, 'is_searchable') and grid.is_searchable(column['field']): - searchable - % endif + :sortable="${json.dumps(column.get('sortable', False))|n}" + :searchable="${json.dumps(column.get('searchable', False))|n}" cell-class="c_${column['field']}" :visible="${json.dumps(column.get('visible', True))}"> % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers: diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index c621627a..5169e599 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -57,6 +57,12 @@ class TestGrid(WebTestCase): 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 From e52a83751e8b95c72917277214ff504a0ede13b6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 21 Aug 2024 20:16:03 -0500 Subject: [PATCH 144/211] feat: move "most" filtering logic for grid class to wuttaweb we still define all filters, and the "most important" grid methods for filtering --- tailbone/grids/core.py | 295 +++++++++-------------------------------- 1 file changed, 62 insertions(+), 233 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 92452b31..969be50a 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -196,9 +196,6 @@ class Grid(WuttaGrid): raw_renderers={}, extra_row_class=None, url='#', - joiners={}, - filterable=False, - filters={}, use_byte_string_filters=False, checkboxes=False, checked=None, @@ -263,6 +260,8 @@ class Grid(WuttaGrid): # reference grid.vue_component etc. kwargs.setdefault('vue_tagname', 'tailbone-grid') + self.use_byte_string_filters = use_byte_string_filters + kwargs['key'] = key kwargs['data'] = data super().__init__(request, **kwargs) @@ -286,11 +285,6 @@ class Grid(WuttaGrid): self.invisible = invisible or [] self.extra_row_class = extra_row_class 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.checkboxes = checkboxes self.checked = checked @@ -446,10 +440,14 @@ class Grid(WuttaGrid): self.remove(oldfield) def set_joiner(self, key, joiner): + """ """ if joiner is None: - self.joiners.pop(key, None) + warnings.warn("specifying None is deprecated for Grid.set_joiner(); " + "please use Grid.remove_joiner() instead", + DeprecationWarning, stacklevel=2) + self.remove_joiner(key) else: - self.joiners[key] = joiner + super().set_joiner(key, joiner) def set_sorter(self, key, *args, **kwargs): """ """ @@ -477,33 +475,27 @@ class Grid(WuttaGrid): self.sorters[key] = self.make_sorter(*args, **kwargs) def set_filter(self, key, *args, **kwargs): - if len(args) == 1 and args[0] is None: - self.remove_filter(key) + """ """ + + if len(args) == 1: + 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) + else: + super().set_filter(key, args[0], **kwargs) + + elif len(args) == 0: + super().set_filter(key, **kwargs) + else: - if 'label' not in kwargs and key in self.labels: - kwargs['label'] = self.labels[key] + warnings.warn("multiple args are deprecated for Grid.set_filter(); " + "please refactor your code accordingly", + DeprecationWarning, stacklevel=2) + kwargs.setdefault('label', self.get_label(key)) self.filters[key] = self.make_filter(key, *args, **kwargs) - def remove_filter(self, key): - self.filters.pop(key, None) - - def set_label(self, key, label, column_only=False): - """ - Set/override the label for a column. - - This overrides - :meth:`~wuttaweb:wuttaweb.grids.base.Grid.set_label()` to add - the following params: - - :param column_only: Boolean indicating whether the label - should be applied *only* to the column header (if - ``True``), vs. applying also to the filter (if ``False``). - """ - super().set_label(key, label) - - if not column_only and key in self.filters: - self.filters[key].label = label - def set_click_handler(self, key, handler): if handler: self.click_handlers[key] = handler @@ -702,6 +694,14 @@ 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,16 +726,6 @@ 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. @@ -888,8 +878,8 @@ class Grid(WuttaGrid): # If request has filter settings, grab those, then grab sort/pager # settings from request or session. - elif self.filterable and self.request_has_settings('filter'): - self.update_filter_settings(settings, 'request') + elif self.request_has_settings('filter'): + self.update_filter_settings(settings, src='request') if self.request_has_settings('sort'): self.update_sort_settings(settings, src='request') else: @@ -901,7 +891,7 @@ class Grid(WuttaGrid): # settings from request or session. elif self.request_has_settings('sort'): self.update_sort_settings(settings, src='request') - self.update_filter_settings(settings, 'session') + self.update_filter_settings(settings, src='session') self.update_page_settings(settings) # NOTE: These next two are functionally equivalent, but are kept @@ -911,12 +901,12 @@ 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, 'session') + self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='session') # If request has no settings, grab all from session. elif self.session_has_settings(): - self.update_filter_settings(settings, 'session') + self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='session') self.update_page_settings(settings) @@ -1056,18 +1046,11 @@ class Grid(WuttaGrid): merge('page', int) def request_has_settings(self, type_): - """ - 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 super().request_has_settings(type_): + return True - elif type_ == 'sort': + if type_ == 'sort': # TODO: remove this eventually, but some links in the wild # may still include these params, so leave it for now @@ -1075,14 +1058,6 @@ 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): @@ -1098,72 +1073,6 @@ class Grid(WuttaGrid): return any([key.startswith(f'{prefix}.filter') for key in self.request.session]) - def update_filter_settings(self, settings, source): - """ - Updates a settings dictionary according to filter settings data found - in either the GET query string, or session storage. - - :param settings: Dictionary of initial settings, which is to be updated. - - :param source: String identifying the source to consult for settings - data. Must be one of: ``('request', 'session')``. - """ - if not self.filterable: - return - - for filtr in self.iter_filters(): - prefix = 'filter.{}'.format(filtr.key) - - if source == 'request': - # consider filter active if query string contains a value for it - settings['{}.active'.format(prefix)] = filtr.key in self.request.GET - settings['{}.verb'.format(prefix)] = self.get_setting( - settings, f'{filtr.key}.verb', src='request', default='') - settings['{}.value'.format(prefix)] = self.get_setting( - settings, filtr.key, src='request', default='') - - else: # source = session - settings['{}.active'.format(prefix)] = self.get_setting( - settings, f'{prefix}.active', src='session', - normalize=lambda v: str(v).lower() == 'true', default=False) - settings['{}.verb'.format(prefix)] = self.get_setting( - settings, f'{prefix}.verb', src='session', default='') - settings['{}.value'.format(prefix)] = self.get_setting( - settings, f'{prefix}.value', src='session', default='') - - def update_page_settings(self, settings): - """ - Updates a settings dictionary according to pager settings data found in - either the GET query string, or session storage. - - Note that due to how the actual pager functions, the effective settings - will often come from *both* the request and session. This is so that - e.g. the page size will remain constant (coming from the session) while - the user jumps between pages (which only provides the single setting). - - :param settings: Dictionary of initial settings, which is to be updated. - """ - if not self.paginated: - return - - pagesize = self.request.GET.get('pagesize') - if pagesize is not None: - if pagesize.isdigit(): - settings['pagesize'] = int(pagesize) - else: - pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key)) - if pagesize is not None: - settings['pagesize'] = pagesize - - page = self.request.GET.get('page') - if page is not None: - if page.isdigit(): - settings['page'] = int(page) - else: - page = self.request.session.get('grid.{}.page'.format(self.key)) - if page is not None: - settings['page'] = int(page) - def persist_settings(self, settings, dest='session'): """ """ if dest not in ('defaults', 'session'): @@ -1251,89 +1160,12 @@ class Grid(WuttaGrid): return data - def sort_data(self, data, sorters=None): - """ """ - if sorters is None: - sorters = self.active_sorters - if not sorters: - return data - - # nb. when data is a query, we want to apply sorters in the - # requested order, so the final query has order_by() in the - # correct "as-is" sequence. however when data is a list we - # must do the opposite, applying in the reverse order, so the - # final list has the most "important" sort(s) applied last. - if not isinstance(data, orm.Query): - sorters = reversed(sorters) - - for sorter in sorters: - sortkey = sorter['key'] - sortdir = sorter['dir'] - - # cannot sort unless we have a sorter callable - sortfunc = self.sorters.get(sortkey) - if not sortfunc: - return data - - # join appropriate model if needed - if sortkey in self.joiners and sortkey not in self.joined: - data = self.joiners[sortkey](data) - self.joined.add(sortkey) - - # invoke the sorter - data = sortfunc(data, sortdir) - - return data - - def paginate_data(self, data): - """ - Paginate the given data set according to current settings, and return - the result. - """ - # we of course assume our current page is correct, at first - pager = self.make_pager(data) - - # if pager has detected that our current page is outside the valid - # range, we must re-orient ourself around the "new" (valid) page - if pager.page != self.page: - self.page = pager.page - self.request.session['grid.{}.page'.format(self.key)] = self.page - pager = self.make_pager(data) - - return pager - - def make_pager(self, data): - - # TODO: this seems hacky..normally we expect `data` to be a - # query of course, but in some cases it may be a list instead. - # if so then we can't use ORM pager - if isinstance(data, list): - import paginate - return paginate.Page(data, - items_per_page=self.pagesize, - page=self.page) - - return SqlalchemyOrmPage(data, - items_per_page=self.pagesize, - page=self.page, - url_maker=URLMaker(self.request)) - def make_visible_data(self): - """ - 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.paginated: - self.pager = self.paginate_data(data) - data = self.pager - return data + """ """ + 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): """ """ @@ -1356,7 +1188,7 @@ class Grid(WuttaGrid): includes the context menu items and grid tools. """ if 'grid_columns' not in kwargs: - kwargs['grid_columns'] = self.get_table_columns() + kwargs['grid_columns'] = self.get_vue_columns() if 'grid_data' not in kwargs: kwargs['grid_data'] = self.get_table_data() @@ -1379,6 +1211,7 @@ class Grid(WuttaGrid): return HTML.literal(html) def render_buefy(self, **kwargs): + """ """ warnings.warn("Grid.render_buefy() is deprecated; " "please use Grid.render_complete() instead", DeprecationWarning, stacklevel=2) @@ -1568,23 +1401,19 @@ class Grid(WuttaGrid): def get_vue_columns(self): """ """ - return self.get_table_columns() + columns = super().get_vue_columns() + + for column in columns: + column['visible'] = column['field'] not in self.invisible + + return columns def get_table_columns(self): - """ - 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.is_sortable(name), - 'searchable': self.is_searchable(name), - 'visible': name not in self.invisible, - }) - return columns + """ """ + warnings.warn("grid.get_table_columns() method is deprecated; " + "please use grid.get_vue_columns() instead", + DeprecationWarning, stacklevel=2) + return self.get_vue_columns() def get_uuid_for_row(self, rowobj): @@ -1610,7 +1439,7 @@ class Grid(WuttaGrid): return self._table_data # filter / sort / paginate to get "visible" data - raw_data = self.make_visible_data() + raw_data = self.get_visible_data() data = [] status_map = {} checked = [] From b8131c83933f87eef5a05a08e919791233040b58 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 13:49:57 -0500 Subject: [PATCH 145/211] fix: change grid reset-view param name to match wuttaweb --- tailbone/grids/core.py | 2 +- tailbone/templates/grids/complete.mako | 2 +- tailbone/views/master.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 969be50a..e58315d3 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -873,7 +873,7 @@ 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-to-default-filters') == 'true': + if self.request.GET.get('reset-view'): pass # If request has filter settings, grab those, then grab sort/pager diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 54ad0527..49758275 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -683,7 +683,7 @@ this.loading = true // use current url proper, plus reset param - let url = '?reset-to-default-filters=true' + let url = '?reset-view=true' // add current hash, to preserve that in redirect if (location.hash) { diff --git a/tailbone/views/master.py b/tailbone/views/master.py index e4d6c3f6..c53fd8b4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -335,7 +335,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-to-default-filters') == 'true': + if self.request.GET.get('reset-view'): kw = {'_query': None} hash_ = self.request.GET.get('hash') if hash_: @@ -1184,7 +1184,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-to-default-filters') == 'true': + if self.request.GET.get('reset-view'): kw = {'_query': None} hash_ = self.request.GET.get('hash') if hash_: From 8d5427e92f9fe272ad1ceb4a6a1b5b0c3cd4ef27 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 14:53:59 -0500 Subject: [PATCH 146/211] =?UTF-8?q?bump:=20version=200.20.1=20=E2=86=92=20?= =?UTF-8?q?0.21.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 6 +++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e2b348a..c54d5642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index 90ecd953..613d3272 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.20.1" +version = "0.21.0" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -53,13 +53,13 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.18.1", + "rattail[db,bouncer]>=0.18.4", "sa-filters", "simplejson", "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.11.0", + "WuttaWeb>=0.12.0", "zope.sqlalchemy>=1.5", ] From f292850d05c7f83334cd2f4156264112e01a4377 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 14:57:39 -0500 Subject: [PATCH 147/211] test: fix some tests --- tests/grids/test_core.py | 2 +- tests/views/wutta/test_people.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py index 5169e599..4d143c85 100644 --- a/tests/grids/test_core.py +++ b/tests/grids/test_core.py @@ -135,7 +135,7 @@ class TestGrid(WebTestCase): def test_set_label(self): model = self.app.model - grid = self.make_grid(model_class=model.Setting) + grid = self.make_grid(model_class=model.Setting, filterable=True) self.assertEqual(grid.labels, {}) # basic diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py index f178a64f..31aeb501 100644 --- a/tests/views/wutta/test_people.py +++ b/tests/views/wutta/test_people.py @@ -38,7 +38,7 @@ class TestPersonView(WebTestCase): def test_configure_form(self): model = self.app.model - barney = model.User(username='barney') + barney = model.Person(display_name="Barney Rubble") self.session.add(barney) self.session.commit() view = self.make_view() From 7b40c527c860e95be4dd74e09b2344b672110d98 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 15:14:11 -0500 Subject: [PATCH 148/211] fix: misc. bugfixes per recent changes --- tailbone/grids/core.py | 23 +++++++++-------------- tailbone/views/email.py | 11 +++++------ 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index e58315d3..754868bc 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -260,6 +260,9 @@ class Grid(WuttaGrid): # 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 @@ -279,7 +282,6 @@ class Grid(WuttaGrid): self.width = width self.enums = enums or {} - self.assume_local_times = assume_local_times self.renderers = self.make_default_renderers(self.renderers) self.raw_renderers = raw_renderers or {} self.invisible = invisible or [] @@ -476,25 +478,18 @@ class Grid(WuttaGrid): 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) - else: - super().set_filter(key, args[0], **kwargs) + return - elif len(args) == 0: - super().set_filter(key, **kwargs) - - else: - warnings.warn("multiple args are deprecated for Grid.set_filter(); " - "please refactor your code accordingly", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('label', self.get_label(key)) - self.filters[key] = self.make_filter(key, *args, **kwargs) + # 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_click_handler(self, key, handler): if handler: @@ -1230,7 +1225,7 @@ class Grid(WuttaGrid): context['data_prop'] = data_prop context['empty_labels'] = empty_labels if 'grid_columns' not in context: - context['grid_columns'] = self.get_table_columns() + context['grid_columns'] = self.get_vue_columns() context.setdefault('paginated', False) if context['paginated']: context.setdefault('per_page', 20) diff --git a/tailbone/views/email.py b/tailbone/views/email.py index a99e8553..98bd4295 100644 --- a/tailbone/views/email.py +++ b/tailbone/views/email.py @@ -116,11 +116,12 @@ class EmailSettingView(MasterView): return data def configure_grid(self, g): - 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') + super().configure_grid(g) + + g.sort_on_backend = False + g.sort_multiple = False g.set_sort_defaults('key') + g.set_type('enabled', 'boolean') g.set_link('key') g.set_link('subject') @@ -130,11 +131,9 @@ 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') From 7d6f75bb05bbbe2345e0f220f9c7a536c8f119e3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 15:33:28 -0500 Subject: [PATCH 149/211] =?UTF-8?q?bump:=20version=200.21.0=20=E2=86=92=20?= =?UTF-8?q?0.21.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c54d5642..3bcbc6ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.21.1 (2024-08-22) + +### Fix + +- misc. bugfixes per recent changes + ## v0.21.0 (2024-08-22) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 613d3272..2db880ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.0" +version = "0.21.1" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From c176d978701648904c1cd00725cf9057fafbe26e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 15:54:15 -0500 Subject: [PATCH 150/211] fix: avoid deprecated `component` form kwarg --- tailbone/views/batch/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 5dd7b548..8ee3a37d 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -861,7 +861,7 @@ class BatchMasterView(MasterView): if not schema: schema = colander.Schema() - kwargs['component'] = 'execute-form' + kwargs['vue_tagname'] = 'execute-form' form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs) self.configure_execute_form(form) return form From 4c3e3aeb6a70ae45eb16a90cc53c1af336e6d083 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 17:09:58 -0500 Subject: [PATCH 151/211] fix: various fixes for waterpark theme --- tailbone/templates/base.mako | 2 +- tailbone/templates/themes/waterpark/base.mako | 83 +++++++++++++++++++ tailbone/templates/themes/waterpark/form.mako | 8 ++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index eb950011..c01b3b37 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -668,7 +668,7 @@ text="Edit This"> </once-button> % endif - % if getattr(master, 'cloneable', False) and master.has_perm('clone'): + % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'): <once-button tag="a" href="${master.get_action_url('clone', instance)}" icon-left="object-ungroup" text="Clone This"> diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako index 878090dc..520e18ce 100644 --- a/tailbone/templates/themes/waterpark/base.mako +++ b/tailbone/templates/themes/waterpark/base.mako @@ -7,6 +7,7 @@ <%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, @@ -171,6 +172,88 @@ % endif </%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: diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako index cf1ddb8a..f88d6821 100644 --- a/tailbone/templates/themes/waterpark/form.mako +++ b/tailbone/templates/themes/waterpark/form.mako @@ -1,2 +1,10 @@ ## -*- 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> From 29531c83c4b785e2ef7b5c4006bd4c86c7b5f045 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 19:21:48 -0500 Subject: [PATCH 152/211] fix: some fixes for wutta people view --- tailbone/grids/core.py | 35 +++++++++++++++++++++++++--------- tailbone/views/master.py | 6 ++++-- tailbone/views/wutta/people.py | 12 +++++++++++- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 754868bc..afd6e11b 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -24,9 +24,10 @@ Core Grid Classes """ -from urllib.parse import urlencode -import warnings +import inspect import logging +import warnings +from urllib.parse import urlencode import sqlalchemy as sa from sqlalchemy import orm @@ -858,9 +859,13 @@ class Grid(WuttaGrid): settings['page'] = self.page if self.filterable: for filtr in self.iter_filters(): - 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 + 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) # If user has default settings on file, apply those first. if self.user_has_defaults(): @@ -1239,7 +1244,7 @@ class Grid(WuttaGrid): view = None for action in self.actions: if action.key == 'view': - return action.click_handler + return getattr(action, 'click_handler', None) def set_filters_sequence(self, filters, only=False): """ @@ -1475,10 +1480,22 @@ 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: - value = self.renderers[name](rowobj, name) - else: - value = self.obtain_value(rowobj, name) + 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) + if value is None: value = "" diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c53fd8b4..1028ff27 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -612,7 +612,9 @@ 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)) + actions.append(self.make_action('delete', icon='trash', + url=self.row_delete_action_url, + link_class='has-text-danger')) defaults['delete_speedbump'] = self.rows_deletable_speedbump defaults['actions'] = actions @@ -3322,7 +3324,7 @@ class MasterView(View): url=self.default_clone_url) def make_grid_action_delete(self): - kwargs = {} + kwargs = {'link_class': 'has-text-danger'} if self.delete_confirm == 'simple': kwargs['click_handler'] = 'deleteObject' return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs) diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py index 968eaf3d..bd96bd4d 100644 --- a/tailbone/views/wutta/people.py +++ b/tailbone/views/wutta/people.py @@ -32,6 +32,7 @@ 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): @@ -44,7 +45,6 @@ class PersonView(wutta.PersonView): """ model_class = Person Session = Session - sort_defaults = 'display_name' labels = { 'display_name': "Full Name", @@ -59,6 +59,11 @@ class PersonView(wutta.PersonView): 'merge_requested', ] + filter_defaults = { + 'display_name': {'active': True, 'verb': 'contains'}, + } + sort_defaults = 'display_name' + form_fields = [ 'first_name', 'middle_name', @@ -74,6 +79,11 @@ class PersonView(wutta.PersonView): # 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) From cea3e4b927eab7114dd0548d6216df8c33dd37a4 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 19:40:21 -0500 Subject: [PATCH 153/211] fix: add basic wutta view for users just proving concepts still at this point..nothing reliable --- tailbone/templates/base.mako | 6 +++- tailbone/views/users.py | 6 +++- tailbone/views/wutta/users.py | 57 +++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 tailbone/views/wutta/users.py diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index c01b3b37..86b1ba1d 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -642,7 +642,11 @@ % if request.is_root or not request.user.prevent_password_change: ${h.link_to("Change Password", url('change_password'), class_='navbar-item')} % endif - ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + % try: + ## nb. does not exist yet for wuttaweb + ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')} + % except: + % endtry ${h.link_to("Logout", url('logout'), class_='navbar-item')} </div> </div> diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 9b533efe..dfed0a11 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -801,4 +801,8 @@ def defaults(config, **kwargs): def includeme(config): - defaults(config) + wutta_config = config.registry.settings['wutta_config'] + if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False): + config.include('tailbone.views.wutta.users') + else: + defaults(config) diff --git a/tailbone/views/wutta/users.py b/tailbone/views/wutta/users.py new file mode 100644 index 00000000..3c3f8d52 --- /dev/null +++ b/tailbone/views/wutta/users.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +User Views +""" + +from wuttaweb.views import users as wutta +from tailbone.views import users as tailbone +from tailbone.db import Session +from rattail.db.model import User +from tailbone.grids import Grid + + +class UserView(wutta.UserView): + """ + This is the first attempt at blending newer Wutta views with + legacy Tailbone config. + + So, this is a Wutta-based view but it should be included by a + Tailbone app configurator. + """ + model_class = User + Session = Session + + # TODO: must use older grid for now, to render filters correctly + def make_grid(self, **kwargs): + """ """ + return Grid(self.request, **kwargs) + + +def defaults(config, **kwargs): + kwargs.setdefault('UserView', UserView) + tailbone.defaults(config, **kwargs) + + +def includeme(config): + defaults(config) From 37f760959d277c2fe158c500c65684fb5af49102 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 22 Aug 2024 19:58:27 -0500 Subject: [PATCH 154/211] fix: merge filters into main grid template to better match wuttaweb --- tailbone/grids/core.py | 22 --------- tailbone/templates/grids/complete.mako | 66 ++++++++++++++++++++++++- tailbone/templates/grids/filters.mako | 67 -------------------------- 3 files changed, 64 insertions(+), 91 deletions(-) delete mode 100644 tailbone/templates/grids/filters.mako diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index afd6e11b..12e45aec 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1318,28 +1318,6 @@ class Grid(WuttaGrid): return data - def render_filters(self, template='/grids/filters.mako', **kwargs): - """ - Render the filters to a Unicode string, using the specified template. - Additional kwargs are passed along as context to the template. - """ - # Provide default data to filters form, so renderer can do some of the - # work for us. - data = {} - for filtr in self.iter_active_filters(): - data['{}.active'.format(filtr.key)] = filtr.active - data['{}.verb'.format(filtr.key)] = filtr.verb - data[filtr.key] = filtr.value - - form = gridfilters.GridFiltersForm(self.filters, - request=self.request, - defaults=data) - - kwargs['request'] = self.request - kwargs['grid'] = self - kwargs['form'] = form - return render(template, kwargs) - def render_actions(self, row, i): # pragma: no cover """ """ warnings.warn("grid.render_actions() is deprecated!", diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index 49758275..f5d1da95 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -10,8 +10,70 @@ <div style="display: flex; flex-direction: column; justify-content: end;"> <div class="filters"> % if getattr(grid, 'filterable', False): - ## TODO: stop using |n filter - ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n} + <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> % endif </div> </div> diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako deleted file mode 100644 index 9a80b911..00000000 --- a/tailbone/templates/grids/filters.mako +++ /dev/null @@ -1,67 +0,0 @@ -## -*- coding: utf-8; -*- - -<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()"> - - <div style="display: flex; flex-direction: column; gap: 0.5rem;"> - <grid-filter v-for="key in filtersSequence" - :key="key" - :filter="filters[key]" - ref="gridFilters"> - </grid-filter> - </div> - - <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;"> - - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="check"> - Apply Filters - </b-button> - - <b-button v-if="!addFilterShow" - icon-pack="fas" - icon-left="plus" - @click="addFilterInit()"> - Add Filter - </b-button> - - <b-autocomplete v-if="addFilterShow" - ref="addFilterAutocomplete" - :data="addFilterChoices" - v-model="addFilterTerm" - placeholder="Add Filter" - field="key" - :custom-formatter="formatAddFilterItem" - open-on-focus - keep-first - icon-pack="fas" - clearable - clear-on-select - @select="addFilterSelect"> - </b-autocomplete> - - <b-button @click="resetView()" - icon-pack="fas" - icon-left="home"> - Default View - </b-button> - - <b-button @click="clearFilters()" - icon-pack="fas" - icon-left="trash"> - No Filters - </b-button> - - % if allow_save_defaults and request.user: - <b-button @click="saveDefaults()" - icon-pack="fas" - icon-left="save" - :disabled="savingDefaults"> - {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }} - </b-button> - % endif - - </div> - -</form> From c1a2c9cc70b36044fb7a82bedf3d5cd59f5cd487 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Aug 2024 14:14:03 -0500 Subject: [PATCH 155/211] fix: tweak how grid data translates to Vue template context per wuttaweb changes --- tailbone/grids/core.py | 6 ++++++ tailbone/templates/grids/complete.mako | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 12e45aec..ecf462fd 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1403,6 +1403,10 @@ 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() @@ -1506,6 +1510,8 @@ class Grid(WuttaGrid): results = { 'data': data, + 'row_classes': status_map, + # TODO: deprecate / remove this 'row_status_map': status_map, } diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako index f5d1da95..60f9a3b8 100644 --- a/tailbone/templates/grids/complete.mako +++ b/tailbone/templates/grids/complete.mako @@ -311,7 +311,8 @@ <script type="text/javascript"> - let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n} + const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n} + let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data let ${grid.vue_component}Data = { loading: false, From b7991b5dc61ff40e268f69be269adacb931519a0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 23 Aug 2024 16:18:17 -0500 Subject: [PATCH 156/211] fix: fix input/output file upload feature for configure pages, per oruga --- tailbone/templates/configure.mako | 170 ++++++++++++++++++------------ 1 file changed, 101 insertions(+), 69 deletions(-) diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 6d9c2261..463d48b1 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="null">-new-</option> + <option value="">-new-</option> <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']" :key="option" :value="option"> @@ -104,22 +104,40 @@ <b-field label="Upload" v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']"> - <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> + % 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> @@ -162,7 +180,7 @@ <b-select name="${tmpl['setting_file']}" v-model="outputFileTemplateSettings['${tmpl['setting_file']}']" @input="settingsNeedSaved = true"> - <option :value="null">-new-</option> + <option value="">-new-</option> <option v-for="option in outputFileTemplateFileOptions['${tmpl['key']}']" :key="option" :value="option"> @@ -174,23 +192,40 @@ <b-field label="Upload" v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !outputFileTemplateSettings['${tmpl['setting_file']}']"> - <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> - + % 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> @@ -275,16 +310,6 @@ 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 @@ -297,30 +322,7 @@ this.purgeSettingsShowDialog = true } - % 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.validateSettings = function() { - let msg - - % if input_file_template_settings is not Undefined: - msg = this.validateInputFileTemplateSettings() - if (msg) { - return msg - } - % endif - } + ThisPage.methods.validateSettings = function() {} ThisPage.methods.saveSettings = function() { let msg @@ -366,6 +368,36 @@ 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 ############################## From d1f4c0f150f51b1fde0bdbdffa5a11d489f4ec9a Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 14:54:45 -0500 Subject: [PATCH 157/211] fix: refactor waterpark base template to use wutta feedback component although for now we still provide the template and add reply-to --- tailbone/templates/themes/waterpark/base.mako | 277 +++++++----------- 1 file changed, 105 insertions(+), 172 deletions(-) diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako index 520e18ce..774479ba 100644 --- a/tailbone/templates/themes/waterpark/base.mako +++ b/tailbone/templates/themes/waterpark/base.mako @@ -164,12 +164,7 @@ /> </div> - % if request.has_perm('common.feedback'): - <feedback-form - action="${url('feedback')}" - :message="feedbackMessage"> - </feedback-form> - % endif + ${parent.render_feedback_button()} </%def> <%def name="render_crud_header_buttons()"> @@ -262,174 +257,133 @@ /> </%def> -<%def name="render_vue_templates()"> - ${parent.render_vue_templates()} +<%def name="render_vue_template_feedback()"> + <script type="text/x-template" id="feedback-template"> + <div> - ${page_help.render_template()} - ${page_help.declare_vars()} + <div class="level-item"> + <b-button type="is-primary" + @click="showFeedback()" + icon-pack="fas" + icon-left="comment"> + Feedback + </b-button> + </div> - % if request.has_perm('common.feedback'): - <script type="text/x-template" id="feedback-template"> - <div> + <b-modal has-modal-card + :active.sync="showDialog"> + <div class="modal-card"> - <div class="level-item"> - <b-button type="is-primary" - @click="showFeedback()" - icon-pack="fas" - icon-left="comment"> - Feedback - </b-button> - </div> + <header class="modal-card-head"> + <p class="modal-card-title">User Feedback</p> + </header> - <b-modal has-modal-card - :active.sync="showDialog"> - <div class="modal-card"> + <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> - <header class="modal-card-head"> - <p class="modal-card-title">User Feedback</p> - </header> + <b-field label="User Name"> + <b-input v-model="userName" + % if request.user: + disabled + % endif + > + </b-input> + </b-field> - <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="Referring URL"> + <b-input + v-model="referrer" + disabled="true"> + </b-input> + </b-field> - <b-field label="User Name"> - <b-input v-model="userName" - % if request.user: - disabled - % endif - > - </b-input> - </b-field> + <b-field label="Message"> + <b-input type="textarea" + v-model="message" + ref="textarea"> + </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> + % 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> - % endif + <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.trim()"> - {{ sendingFeedback ? "Working, please wait..." : "Send Message" }} - </b-button> - </footer> - </div> - </b-modal> + </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> - </script> - <script> + </b-modal> - const FeedbackForm = { - template: '#feedback-template', - mixins: [SimpleRequestMixin], - props: [ - 'action', - 'message', - ], - methods: { + </div> + </script> +</%def> - showFeedback() { - this.referrer = location.href - this.showDialog = true - this.$nextTick(function() { - this.$refs.textarea.focus() - }) - }, +<%def name="render_vue_script_feedback()"> + ${parent.render_vue_script_feedback()} + <script> - % if config.get_bool('tailbone.feedback_allows_reply'): - pleaseReplyChanged(value) { - this.$nextTick(() => { - this.$refs.userEmail.focus() - }) - }, - % endif + WuttaFeedbackForm.template = '#feedback-template' + WuttaFeedbackForm.props.message = String - sendFeedback() { - this.sendingFeedback = true + % if config.get_bool('tailbone.feedback_allows_reply'): - const params = { - referrer: this.referrer, - user: this.userUUID, - user_name: this.userName, - % if config.get_bool('tailbone.feedback_allows_reply'): - please_reply_to: this.pleaseReply ? this.userEmail : null, - % endif - message: this.message.trim(), - } + WuttaFeedbackFormData.pleaseReply = false + WuttaFeedbackFormData.userEmail = null - this.simplePOST(this.action, params, response => { + WuttaFeedbackForm.methods.pleaseReplyChanged = function(value) { + this.$nextTick(() => { + this.$refs.userEmail.focus() + }) + } - this.$buefy.toast.open({ - message: "Message sent! Thank you for your feedback.", - type: 'is-info', - duration: 4000, // 4 seconds - }) - - this.showDialog = false - // clear out message, in case they need to send another - this.message = "" - this.sendingFeedback = false - - }, response => { // failure - this.sendingFeedback = false - }) - }, + WuttaFeedbackForm.methods.getExtraParams = function() { + return { + please_reply_to: this.pleaseReply ? this.userEmail : null, } } - const FeedbackFormData = { - referrer: null, - userUUID: null, - userName: null, - userEmail: null, - % if config.get_bool('tailbone.feedback_allows_reply'): - pleaseReply: false, - % endif - showDialog: false, - sendingFeedback: false, - } + % endif - </script> - % 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()"> @@ -528,21 +482,6 @@ % endif - ############################## - ## feedback - ############################## - - % if request.has_perm('common.feedback'): - - WholePageData.feedbackMessage = "" - - % if request.user: - FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n} - FeedbackFormData.userName = ${json.dumps(str(request.user))|n} - % endif - - % endif - ############################## ## edit fields help ############################## @@ -562,10 +501,4 @@ ${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()} - % if request.has_perm('common.feedback'): - <script> - FeedbackForm.data = function() { return FeedbackFormData } - Vue.component('feedback-form', FeedbackForm) - </script> - % endif </%def> From 3a9bf69aa7f63fc838259eef477324beee7c66a8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 14:56:15 -0500 Subject: [PATCH 158/211] =?UTF-8?q?bump:=20version=200.21.1=20=E2=86=92=20?= =?UTF-8?q?0.21.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bcbc6ec..4616cf5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index 2db880ad..831133c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.1" +version = "0.21.2" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -53,13 +53,13 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.18.4", + "rattail[db,bouncer]>=0.18.5", "sa-filters", "simplejson", "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.12.0", + "WuttaWeb>=0.13.1", "zope.sqlalchemy>=1.5", ] From d67eb2f1cc15719478a26b8b76246947b528885e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 15:24:40 -0500 Subject: [PATCH 159/211] fix: show non-standard config values for app info configure email this page is currently showing some basic email sender/recips etc. but the config keys traditionally used by rattail are different than wuttjamaican..so for now we must "translate" --- tailbone/views/settings.py | 49 ++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 099a77e1..0180aa4b 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -81,15 +81,56 @@ class AppInfoView(WuttaAppInfoView): """ """ simple_settings = super().configure_get_simple_settings() - # TODO: the update home page redirect setting is off by - # default for wuttaweb, but on for tailbone for setting in simple_settings: + + # TODO: 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['default'] = value - break + setting['value'] = value + + # TODO: 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 + + # TODO: email defaults have different config keys in rattail + 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 + + # TODO: email defaults have different config keys in rattail + elif setting['name'] == 'rattail.email.default.subject': + value = self.config.get('rattail.email.default.subject') + if value is None: + value = self.config.get('rattail.mail.default.subject') + setting['value'] = value + + # TODO: email defaults have different config keys in rattail + elif setting['name'] == 'rattail.email.default.to': + value = self.config.get('rattail.email.default.to') + if value is None: + value = self.config.get('rattail.mail.default.to') + setting['value'] = value + + # TODO: email defaults have different config keys in rattail + elif setting['name'] == 'rattail.email.default.cc': + value = self.config.get('rattail.email.default.cc') + if value is None: + value = self.config.get('rattail.mail.default.cc') + setting['value'] = value + + # TODO: email defaults have different config keys in rattail + elif setting['name'] == 'rattail.email.default.bcc': + value = self.config.get('rattail.email.default.bcc') + if value is None: + value = self.config.get('rattail.mail.default.bcc') + setting['value'] = value # nb. these are no longer used (deprecated), but we keep # them defined here so the tool auto-deletes them From dffd951369de5ca36a877f9b8b36e344245266b0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 15:25:56 -0500 Subject: [PATCH 160/211] =?UTF-8?q?bump:=20version=200.21.2=20=E2=86=92=20?= =?UTF-8?q?0.21.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4616cf5f..52a17a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.21.3 (2024-08-26) + +### Fix + +- show non-standard config values for app info configure email + ## v0.21.2 (2024-08-26) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 831133c1..2c18bd02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.2" +version = "0.21.3" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 7a9d5772db794d69632ce3a8621396d08e6ec679 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 16:11:32 -0500 Subject: [PATCH 161/211] fix: handle differing email profile keys for appinfo/configure hopefully this all can improve some day soon.. --- tailbone/templates/configure.mako | 5 +- tailbone/views/settings.py | 96 +++++++++++++++++++++---------- 2 files changed, 69 insertions(+), 32 deletions(-) diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index 463d48b1..e6b128fc 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -280,15 +280,14 @@ <b-button @click="purgeSettingsShowDialog = false"> Cancel </b-button> - ${h.form(request.current_route_url())} + ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})} ${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" - @click="purgingSettings = true"> + icon-left="trash"> {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }} </b-button> ${h.end_form()} diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py index 0180aa4b..10a0c2eb 100644 --- a/tailbone/views/settings.py +++ b/tailbone/views/settings.py @@ -77,13 +77,41 @@ class AppInfoView(WuttaAppInfoView): return context + # nb. these email settings require special handling below + configure_profile_key_mismatches = [ + 'default.subject', + 'default.to', + 'default.cc', + 'default.bcc', + 'feedback.subject', + 'feedback.to', + ] + def configure_get_simple_settings(self): """ """ simple_settings = super().configure_get_simple_settings() + # TODO: + # there are several email config keys which differ between + # wuttjamaican and rattail. basically all of the "profile" keys + # have a different prefix. + + # 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!) + + # 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. + + # there are also a couple of flags where rattail's default is the + # opposite of wuttjamaican. so we overwrite those too as needed. + for setting in simple_settings: - # TODO: the update home page redirect setting is off by + # 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') @@ -91,55 +119,43 @@ class AppInfoView(WuttaAppInfoView): value = self.config.get_bool('tailbone.login_is_home', default=True) setting['value'] = value - # TODO: sending email is off by default for wuttjamaican, + # 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 - # TODO: email defaults have different config keys in rattail + # 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 - # TODO: email defaults have different config keys in rattail - elif setting['name'] == 'rattail.email.default.subject': - value = self.config.get('rattail.email.default.subject') - if value is None: - value = self.config.get('rattail.mail.default.subject') - setting['value'] = value + else: - # TODO: email defaults have different config keys in rattail - elif setting['name'] == 'rattail.email.default.to': - value = self.config.get('rattail.email.default.to') - if value is None: - value = self.config.get('rattail.mail.default.to') - setting['value'] = value - - # TODO: email defaults have different config keys in rattail - elif setting['name'] == 'rattail.email.default.cc': - value = self.config.get('rattail.email.default.cc') - if value is None: - value = self.config.get('rattail.mail.default.cc') - setting['value'] = value - - # TODO: email defaults have different config keys in rattail - elif setting['name'] == 'rattail.email.default.bcc': - value = self.config.get('rattail.email.default.bcc') - if value is None: - value = self.config.get('rattail.mail.default.bcc') - setting['value'] = value + # 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'}, ]) + simple_settings.append({'name': 'rattail.mail.default.from'}) + for key in self.configure_profile_key_mismatches: + simple_settings.append({'name': f'rattail.mail.{key}'}) + for key in self.get_weblibs(): simple_settings.extend([ {'name': f'tailbone.libver.{key}'}, @@ -148,6 +164,28 @@ class AppInfoView(WuttaAppInfoView): return simple_settings + def configure_gather_settings(self, data, simple_settings=None): + """ """ + settings = super().configure_gather_settings(data, simple_settings=simple_settings) + + # nb. must add legacy rattail profile settings to match new ones + for setting in list(settings): + + if setting['name'] == 'rattail.email.default.sender': + value = setting['value'] + settings.append({'name': 'rattail.mail.default.from', + 'value': value}) + + else: + for key in self.configure_profile_key_mismatches: + if setting['name'] == f'rattail.email.{key}': + value = setting['value'] + settings.append({'name': f'rattail.mail.{key}', + 'value': value}) + break + + return settings + class SettingView(MasterView): """ From ca05e688905398758470d5dd2db0ba288b8216a5 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 26 Aug 2024 16:12:14 -0500 Subject: [PATCH 162/211] =?UTF-8?q?bump:=20version=200.21.3=20=E2=86=92=20?= =?UTF-8?q?0.21.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a17a2f..e18c786c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.21.4 (2024-08-26) + +### Fix + +- handle differing email profile keys for appinfo/configure + ## v0.21.3 (2024-08-26) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 2c18bd02..4845708b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.3" +version = "0.21.4" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 2e20fc5b7527275eaf7408dad56e3516ef6433e3 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 27 Aug 2024 13:50:30 -0500 Subject: [PATCH 163/211] fix: set empty string for "-new-" file configure option otherwise the "-new-" option is not properly auto-selected --- tailbone/views/master.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 1028ff27..6e05c35d 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -5441,7 +5441,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'] + 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'] @@ -5457,7 +5457,7 @@ class MasterView(View): for template in self.normalize_output_file_templates( include_file_options=True): settings[template['setting_mode']] = template['mode'] - settings[template['setting_file']] = template['file'] + settings[template['setting_file']] = template['file'] or '' settings[template['setting_url']] = template['url'] file_options[template['key']] = template['file_options'] file_option_dirs[template['key']] = template['file_options_dir'] From b30f066c41f3b758882e0d8fc68e4a61b501e186 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 00:30:15 -0500 Subject: [PATCH 164/211] =?UTF-8?q?bump:=20version=200.21.4=20=E2=86=92=20?= =?UTF-8?q?0.21.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e18c786c..d3c8a92f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.21.5 (2024-08-28) + +### Fix + +- set empty string for "-new-" file configure option + ## v0.21.4 (2024-08-26) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 4845708b..4743fd3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.4" +version = "0.21.5" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.13.1", + "WuttaWeb>=0.14.0", "zope.sqlalchemy>=1.5", ] From b81914fbf52357e3097a8f88d913c19ef30c0388 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 00:35:15 -0500 Subject: [PATCH 165/211] test: fix broken test --- tests/test_app.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index e16461ba..f49f6b13 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,12 +5,9 @@ from unittest import TestCase from pyramid.config import Configurator -from wuttjamaican.testing import FileConfigTestCase - from rattail.exceptions import ConfigurationError -from rattail.config import RattailConfig +from rattail.testing import DataTestCase from tailbone import app as mod -from tests.util import DataTestCase class TestRattailConfig(TestCase): @@ -30,7 +27,7 @@ class TestRattailConfig(TestCase): class TestMakePyramidConfig(DataTestCase): - def make_config(self): + def make_config(self, **kwargs): myconf = self.write_file('web.conf', """ [rattail.db] default.url = sqlite:// From 0b6cfaa9c57bbbf0ef3ad51cab4e5d5bc56d6843 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 09:53:14 -0500 Subject: [PATCH 166/211] fix: avoid error when grid value cannot be obtained --- tailbone/grids/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index ecf462fd..c6257d4b 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -575,7 +575,11 @@ class Grid(WuttaGrid): return getattr(obj, column_name) except AttributeError: pass - return obj[column_name] + + try: + return obj[column_name] + except TypeError: + pass def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) From 71d63f6b93fee7ff8ff2ff19eebe844dce9476df Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 09:53:37 -0500 Subject: [PATCH 167/211] =?UTF-8?q?bump:=20version=200.21.5=20=E2=86=92=20?= =?UTF-8?q?0.21.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c8a92f..59fcfcc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.21.6 (2024-08-28) + +### Fix + +- avoid error when grid value cannot be obtained + ## v0.21.5 (2024-08-28) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 4743fd3b..16018dbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.5" +version = "0.21.6" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From bc399182ba5eb957ae7c521f3b71701ff4bf39d1 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 14:20:17 -0500 Subject: [PATCH 168/211] fix: avoid error when form value cannot be obtained --- tailbone/forms/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 059b212a..b5020975 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1380,7 +1380,11 @@ class Form(object): return getattr(record, field_name) except AttributeError: pass - return record[field_name] + + try: + return record[field_name] + except TypeError: + pass # TODO: is this always safe to do? elif self.defaults and field_name in self.defaults: From 20dcdd8b86dfdbab1224676e3135ee8171b57f00 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 14:20:51 -0500 Subject: [PATCH 169/211] =?UTF-8?q?bump:=20version=200.21.6=20=E2=86=92=20?= =?UTF-8?q?0.21.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59fcfcc9..aee19700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.21.7 (2024-08-28) + +### Fix + +- avoid error when form value cannot be obtained + ## v0.21.6 (2024-08-28) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 16018dbb..45a2adc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.6" +version = "0.21.7" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 812d8d2349e7517e2ef5702dcf904cd0b5c5c8af Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 14:37:18 -0500 Subject: [PATCH 170/211] fix: ignore session kwarg for `MasterView.make_row_grid()` --- tailbone/views/master.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 6e05c35d..baf63caa 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -551,7 +551,8 @@ 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, **kwargs): + def make_row_grid(self, factory=None, key=None, data=None, columns=None, + session=None, **kwargs): """ Make and return a new (configured) rows grid instance. """ From 9be2f6347571d5989fabad88a9fc90ebf63812f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 14:37:40 -0500 Subject: [PATCH 171/211] =?UTF-8?q?bump:=20version=200.21.7=20=E2=86=92=20?= =?UTF-8?q?0.21.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aee19700..a31b80ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.21.8 (2024-08-28) + +### Fix + +- ignore session kwarg for `MasterView.make_row_grid()` + ## v0.21.7 (2024-08-28) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 45a2adc9..350803dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.7" +version = "0.21.8" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 2219cf81988c583320014492a6e114c40e025e2b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 17:38:05 -0500 Subject: [PATCH 172/211] fix: render custom attrs in form component tag --- tailbone/forms/core.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index b5020975..601dcfb1 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -1037,9 +1037,9 @@ class Form(object): def render_vue_tag(self, **kwargs): """ """ - return self.render_vuejs_component() + return self.render_vuejs_component(**kwargs) - def render_vuejs_component(self): + def render_vuejs_component(self, **kwargs): """ Render the Vue.js component HTML for the form. @@ -1050,10 +1050,11 @@ class Form(object): <tailbone-form :configure-fields-help="configureFieldsHelp"> </tailbone-form> """ - kwargs = dict(self.vuejs_component_kwargs) + kw = dict(self.vuejs_component_kwargs) + kw.update(kwargs) if self.can_edit_help: - kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') - return HTML.tag(self.vue_tagname, **kwargs) + kw.setdefault(':configure-fields-help', 'configureFieldsHelp') + return HTML.tag(self.vue_tagname, **kw) def set_json_data(self, key, value): """ From 55f45ae8a081123af3c8fc931a7745f0d7ea0b2b Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 28 Aug 2024 17:38:33 -0500 Subject: [PATCH 173/211] =?UTF-8?q?bump:=20version=200.21.8=20=E2=86=92=20?= =?UTF-8?q?0.21.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a31b80ac..da628cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.21.9 (2024-08-28) + +### Fix + +- render custom attrs in form component tag + ## v0.21.8 (2024-08-28) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 350803dc..2720d003 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.8" +version = "0.21.9" description = "Backoffice Web Application for Rattail" readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 8df52bf2a2d8902cc1565a5e46370273db580be2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 29 Aug 2024 17:01:28 -0500 Subject: [PATCH 174/211] fix: expose datasync consumer batch size via configure page --- tailbone/templates/datasync/configure.mako | 29 ++++++---- tailbone/views/datasync.py | 65 +++++++++++++--------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 3651d0c4..2e444fb5 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -83,8 +83,8 @@ </b-notification> <b-field> - <b-checkbox name="use_profile_settings" - v-model="useProfileSettings" + <b-checkbox name="rattail.datasync.use_profile_settings" + v-model="simpleSettings['rattail.datasync.use_profile_settings']" 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="useProfileSettings"> + v-show="simpleSettings['rattail.datasync.use_profile_settings']"> <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="useProfileSettings"> + v-if="simpleSettings['rattail.datasync.use_profile_settings']"> <a href="#" class="grid-action" @click.prevent="editProfile(props.row)"> @@ -580,18 +580,27 @@ <b-field label="Supervisor Process Name" message="This should be the complete name, including group - e.g. poser:poser_datasync" expanded> - <b-input name="supervisor_process_name" - v-model="supervisorProcessName" + <b-input name="rattail.datasync.supervisor_process_name" + v-model="simpleSettings['rattail.datasync.supervisor_process_name']" @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="restart_command" - v-model="restartCommand" + <b-input name="tailbone.datasync.restart" + v-model="simpleSettings['tailbone.datasync.restart']" @input="settingsNeedSaved = true" expanded> </b-input> @@ -606,7 +615,6 @@ 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 @@ -631,9 +639,6 @@ 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 diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 134d6018..2b955b5f 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -202,10 +202,36 @@ class DataSyncThreadView(MasterView): return self.redirect(self.request.get_referrer( default=self.request.route_url('datasyncchanges'))) - def configure_get_context(self): + def configure_get_simple_settings(self): + """ """ + return [ + + # basic + {'section': 'rattail.datasync', + 'option': 'use_profile_settings', + 'type': bool}, + + # misc. + {'section': 'rattail.datasync', + 'option': 'supervisor_process_name'}, + {'section': 'rattail.datasync', + 'option': 'batch_size_limit', + 'type': int}, + + # legacy + {'section': 'tailbone', + 'option': 'datasync.restart'}, + + ] + + def configure_get_context(self, **kwargs): + """ """ + context = super().configure_get_context(**kwargs) + profiles = self.datasync_handler.get_configured_profiles( include_disabled=True, ignore_problems=True) + context['profiles'] = profiles profiles_data = [] for profile in sorted(profiles.values(), key=lambda p: p.key): @@ -243,25 +269,15 @@ class DataSyncThreadView(MasterView): data['consumers_data'] = consumers profiles_data.append(data) - 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'), - } + context['profiles_data'] = profiles_data + return context - def configure_gather_settings(self, data): - settings = [] - watch = [] + def configure_gather_settings(self, data, **kwargs): + """ """ + settings = super().configure_gather_settings(data, **kwargs) - 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: + if data.get('rattail.datasync.use_profile_settings') == 'true': + watch = [] for profile in json.loads(data['profiles']): pkey = profile['key'] @@ -323,17 +339,12 @@ 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): + def configure_remove_settings(self, **kwargs): + """ """ + super().configure_remove_settings(**kwargs) + purge_datasync_settings(self.rattail_config, self.Session()) @classmethod From b9b8bbd2eae1543cb74898f95e72cee5e7de6f46 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 29 Aug 2024 17:18:32 -0500 Subject: [PATCH 175/211] fix: wrap notes text for batch view --- tailbone/views/batch/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 8ee3a37d..a75fda1c 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -383,7 +383,7 @@ class BatchMasterView(MasterView): f.set_label('executed_by', "Executed by") # notes - f.set_type('notes', 'text') + f.set_type('notes', 'text_wrapped') # if self.creating and self.request.user: # batch = fs.model From 5e742eab1795fe4c53573070af264c8d8a4cf3c0 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 9 Sep 2024 08:32:28 -0500 Subject: [PATCH 176/211] fix: use better icon for submit button on login page --- tailbone/forms/core.py | 2 ++ tailbone/templates/forms/deform.mako | 2 +- tailbone/views/auth.py | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 601dcfb1..4024557b 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -401,6 +401,8 @@ 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) diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index ea35ab17..2100b460 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -59,7 +59,7 @@ native-type="submit" :disabled="${form.vue_component}Submitting" icon-pack="fas" - icon-left="save"> + icon-left="${form.button_icon_submit}"> {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }} </b-button> % else: diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 730d7b6a..a54a19a9 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -24,8 +24,6 @@ Auth Views """ -from rattail.db.auth import set_user_password - import colander from deform import widget as dfwidget from pyramid.httpexceptions import HTTPForbidden @@ -104,6 +102,7 @@ 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']) @@ -185,7 +184,8 @@ class AuthenticationView(View): schema = ChangePassword().bind(user=self.request.user, request=self.request) form = forms.Form(schema=schema, request=self.request) if form.validate(): - set_user_password(self.request.user, form.validated['new_password']) + auth = self.app.get_auth_handler() + auth.set_user_password(self.request.user, form.validated['new_password']) self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) From a4d81a6e3cf431bae5fb91337ccf1c345e75c137 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 13 Sep 2024 18:16:07 -0500 Subject: [PATCH 177/211] docs: use markdown for readme file --- README.rst => README.md | 8 +++----- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) rename README.rst => README.md (56%) diff --git a/README.rst b/README.md similarity index 56% rename from README.rst rename to README.md index 0cffc62d..74c007f6 100644 --- a/README.rst +++ b/README.md @@ -1,10 +1,8 @@ -Tailbone -======== +# Tailbone Tailbone is an extensible web application based on Rattail. It provides a "back-office network environment" (BONE) for use in managing retail data. -Please see Rattail's `home page`_ for more information. - -.. _home page: http://rattailproject.org/ +Please see Rattail's [home page](http://rattailproject.org/) for more +information. diff --git a/pyproject.toml b/pyproject.toml index 2720d003..8c6525c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "hatchling.build" name = "Tailbone" version = "0.21.9" description = "Backoffice Web Application for Rattail" -readme = "README.rst" +readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] license = {text = "GNU GPL v3+"} classifiers = [ From 0b646d2d187fafe743cb7816ab0a86d171b76646 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 14 Sep 2024 12:49:37 -0500 Subject: [PATCH 178/211] fix: update project repo links, kallithea -> forgejo --- pyproject.toml | 6 ++-- tailbone/views/upgrades.py | 69 +++++++++++--------------------------- 2 files changed, 23 insertions(+), 52 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8c6525c6..a1c96dd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,9 +84,9 @@ tailbone = "tailbone.config:ConfigExtension" [project.urls] Homepage = "https://rattailproject.org" -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" +Repository = "https://forgejo.wuttaproject.org/rattail/tailbone" +Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues" +Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md" [tool.commitizen] diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index 3276b64d..ffa88032 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -348,56 +348,27 @@ class UpgradeView(MasterView): commit_hash_pattern = re.compile(r'^.{40}$') def get_changelog_projects(self): - 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', - }, + 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 = {} + 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): From 0b4efae392ff35ca4a0d0ac1ea59859b25e084f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 15 Sep 2024 10:56:01 -0500 Subject: [PATCH 179/211] =?UTF-8?q?bump:=20version=200.21.9=20=E2=86=92=20?= =?UTF-8?q?0.21.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da628cf3..73c8b72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index a1c96dd4..3368842b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.9" +version = "0.21.10" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 2308d2e2408ea5429ce196ed6c193241a21742a8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 16 Sep 2024 12:55:58 -0500 Subject: [PATCH 180/211] fix: become/stop root should redirect to previous url for default theme; butterball already did that --- tailbone/templates/base.mako | 18 ++++++++++++++++-- tailbone/templates/themes/butterball/base.mako | 16 ++-------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 86b1ba1d..8228f823 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -632,9 +632,23 @@ % endif <div class="navbar-dropdown"> % if request.is_root: - ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} + ${h.form(url('stop_root'), ref='stopBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.stopBeingRootForm.submit()" + class="navbar-item root-user"> + Stop being root + </a> + ${h.end_form()} % elif request.is_admin: - ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} + ${h.form(url('become_root'), ref='startBeingRootForm')} + ${h.csrf_token(request)} + <input type="hidden" name="referrer" value="${request.current_route_url()}" /> + <a @click="$refs.startBeingRootForm.submit()" + class="navbar-item root-user"> + Become root + </a> + ${h.end_form()} % endif % if messaging_enabled: ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index 14616474..b69eacfb 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -909,7 +909,7 @@ ${h.form(url('stop_root'), ref='stopBeingRootForm')} ${h.csrf_token(request)} <input type="hidden" name="referrer" value="${request.current_route_url()}" /> - <a @click="stopBeingRoot()" + <a @click="$refs.stopBeingRootForm.submit()" class="navbar-item has-background-danger has-text-white"> Stop being root </a> @@ -918,7 +918,7 @@ ${h.form(url('become_root'), ref='startBeingRootForm')} ${h.csrf_token(request)} <input type="hidden" name="referrer" value="${request.current_route_url()}" /> - <a @click="startBeingRoot()" + <a @click="$refs.startBeingRootForm.submit()" class="navbar-item has-background-danger has-text-white"> Become root </a> @@ -1103,18 +1103,6 @@ const key = 'menu_' + hash + '_shown' this[key] = !this[key] }, - - % if request.is_admin: - - startBeingRoot() { - this.$refs.startBeingRootForm.submit() - }, - - stopBeingRoot() { - this.$refs.stopBeingRootForm.submit() - }, - - % endif }, } From d520f64fee9c2c083e867816e2c90e56028c41f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 3 Oct 2024 08:56:52 -0500 Subject: [PATCH 181/211] fix: custom method for adding grid action since for now, we are using custom grid action class --- tailbone/grids/core.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index c6257d4b..73de42c6 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1544,6 +1544,11 @@ class Grid(WuttaGrid): 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)) + def set_action_urls(self, row, rowobj, i): """ Pre-generate all action URLs for the given data row. Meant for use From c6365f263166c53934fd81083c01d2bceccb01ab Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Thu, 3 Oct 2024 09:05:46 -0500 Subject: [PATCH 182/211] =?UTF-8?q?bump:=20version=200.21.10=20=E2=86=92?= =?UTF-8?q?=200.21.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c8b72b..3c31ae92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index 3368842b..5b63a71f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.10" +version = "0.21.11" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 072db39233dd8c0c22e429202f446cd67f578863 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 22 Oct 2024 14:26:10 -0500 Subject: [PATCH 183/211] feat: add support for new ordering batch from parsed file --- tailbone/api/batch/receiving.py | 30 +- tailbone/templates/ordering/configure.mako | 74 +++++ tailbone/templates/receiving/configure.mako | 8 +- tailbone/views/batch/core.py | 5 +- tailbone/views/purchasing/batch.py | 290 +++++++++++++++++++- tailbone/views/purchasing/ordering.py | 101 ++++++- tailbone/views/purchasing/receiving.py | 219 +++------------ 7 files changed, 498 insertions(+), 229 deletions(-) create mode 100644 tailbone/templates/ordering/configure.mako diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index daa4290f..b23bff55 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -29,8 +29,7 @@ import logging import humanize import sqlalchemy as sa -from rattail.db import model -from rattail.util import pretty_quantity +from rattail.db.model import PurchaseBatch, PurchaseBatchRow from cornice import Service from deform import widget as dfwidget @@ -45,7 +44,7 @@ log = logging.getLogger(__name__) class ReceivingBatchViews(APIBatchView): - model_class = model.PurchaseBatch + model_class = PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receivingbatchviews' permission_prefix = 'receiving' @@ -55,7 +54,8 @@ class ReceivingBatchViews(APIBatchView): supports_execute = True def base_query(self): - query = super(ReceivingBatchViews, self).base_query() + model = self.app.model + query = super().base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) return query @@ -85,7 +85,7 @@ class ReceivingBatchViews(APIBatchView): # assume "receive from PO" if given a PO key if data.get('purchase_key'): - data['receiving_workflow'] = 'from_po' + data['workflow'] = 'from_po' return super().create_object(data) @@ -120,6 +120,7 @@ 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: @@ -176,7 +177,7 @@ class ReceivingBatchViews(APIBatchView): class ReceivingBatchRowViews(APIBatchRowView): - model_class = model.PurchaseBatchRow + model_class = PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receiving.rows' permission_prefix = 'receiving' @@ -185,7 +186,8 @@ class ReceivingBatchRowViews(APIBatchRowView): supports_quick_entry = True def make_filter_spec(self): - filters = super(ReceivingBatchRowViews, self).make_filter_spec() + model = self.app.model + filters = super().make_filter_spec() if filters: # must translate certain convenience filters @@ -296,11 +298,11 @@ class ReceivingBatchRowViews(APIBatchRowView): return filters def normalize(self, row): - data = super(ReceivingBatchRowViews, self).normalize(row) + data = super().normalize(row) + model = self.app.model batch = row.batch - app = self.get_rattail_app() - prodder = app.get_products_handler() + prodder = self.app.get_products_handler() data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id @@ -375,7 +377,7 @@ class ReceivingBatchRowViews(APIBatchRowView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = pretty_quantity(remainder) + remainder = self.app.render_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive Remainder ({} {})".format( remainder, data['unit_uom']) @@ -386,7 +388,7 @@ class ReceivingBatchRowViews(APIBatchRowView): else: # nothing yet accounted for, button should receive "all" if not remainder: log.warning("quick receive remainder is empty for row %s", row.uuid) - remainder = pretty_quantity(remainder) + remainder = self.app.render_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive ALL ({} {})".format( remainder, data['unit_uom']) @@ -414,7 +416,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['received_alert'] = None if self.batch_handler.get_units_confirmed(row): msg = "You have already received some of this product; last update was {}.".format( - humanize.naturaltime(app.make_utc() - row.modified)) + humanize.naturaltime(self.app.make_utc() - row.modified)) data['received_alert'] = msg return data @@ -423,6 +425,8 @@ 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/templates/ordering/configure.mako b/tailbone/templates/ordering/configure.mako new file mode 100644 index 00000000..dc505c42 --- /dev/null +++ b/tailbone/templates/ordering/configure.mako @@ -0,0 +1,74 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + + <h3 class="block is-size-3">Workflows</h3> + <div class="block" style="padding-left: 2rem;"> + + <p class="block"> + Users can only choose from the workflows enabled below. + </p> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']" + native-value="true" + @input="settingsNeedSaved = true"> + From Scratch + </b-checkbox> + </b-field> + + <b-field> + <b-checkbox name="rattail.batch.purchase.allow_ordering_from_file" + v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']" + native-value="true" + @input="settingsNeedSaved = true"> + From Order File + </b-checkbox> + </b-field> + + </div> + + <h3 class="block is-size-3">Vendors</h3> + <div class="block" style="padding-left: 2rem;"> + + <b-field message="If not set, user must choose a "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/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index f613e13e..a36dde43 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 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']" + <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']" native-value="true" @input="settingsNeedSaved = true"> - Only allow batch for "supported" vendors + Allow receiving for <span class="has-text-weight-bold">any</span> vendor </b-checkbox> </b-field> diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index a75fda1c..c162b579 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -46,10 +46,11 @@ 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__) @@ -441,7 +442,7 @@ class BatchMasterView(MasterView): form = [ begin_form, - csrf_token(self.request), + render_csrf_token(self.request), tags.hidden('complete', value=value), submit, tags.end_form(), diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 590b9af5..5e00704e 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -24,6 +24,8 @@ Base class for purchasing batch views """ +import warnings + from rattail.db.model import PurchaseBatch, PurchaseBatchRow import colander @@ -67,6 +69,8 @@ class PurchasingBatchView(BatchMasterView): 'store', 'buyer', 'vendor', + 'description', + 'workflow', 'department', 'purchase', 'vendor_email', @@ -158,6 +162,174 @@ 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)\ @@ -226,20 +398,40 @@ class PurchasingBatchView(BatchMasterView): def configure_form(self, f): super().configure_form(f) - model = self.model + model = self.app.model + enum = self.app.enum + route_prefix = self.get_route_prefix() + + today = self.app.today() batch = f.model_instance - app = self.get_rattail_app() - today = app.localtime().date() + workflow = self.request.matchdict.get('workflow_key') + vendor_handler = self.app.get_vendor_handler() # mode - f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) + f.set_enum('mode', enum.PURCHASE_BATCH_MODE) + + # workflow + if self.creating: + if workflow: + f.set_widget('workflow', dfwidget.HiddenWidget()) + f.set_default('workflow', workflow) + f.set_hidden('workflow') + # nb. show readonly '_workflow' + f.insert_after('workflow', '_workflow') + f.set_readonly('_workflow') + f.set_renderer('_workflow', self.render_workflow) + else: + f.set_readonly('workflow') + f.set_renderer('workflow', self.render_workflow) + else: + f.remove('workflow') # store - single_store = self.rattail_config.single_store() + single_store = self.config.single_store() if self.creating: f.replace('store', 'store_uuid') if single_store: - store = self.rattail_config.get_store(self.Session()) + store = self.config.get_store(self.Session()) f.set_widget('store_uuid', dfwidget.HiddenWidget()) f.set_default('store_uuid', store.uuid) f.set_hidden('store_uuid') @@ -263,7 +455,6 @@ 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)\ @@ -313,7 +504,7 @@ class PurchasingBatchView(BatchMasterView): if buyer: buyer_display = str(buyer) elif self.creating: - buyer = app.get_employee(self.request.user) + buyer = self.app.get_employee(self.request.user) if buyer: buyer_display = str(buyer) f.set_default('buyer_uuid', buyer.uuid) @@ -324,6 +515,30 @@ 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) @@ -341,7 +556,7 @@ class PurchasingBatchView(BatchMasterView): if vendor: kwargs['vendor'] = vendor - parsers = self.handler.get_supported_invoice_parsers(**kwargs) + parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs) parser_values = [(p.key, p.display) for p in parsers] if len(parsers) == 1: f.set_default('invoice_parser_key', parsers[0].key) @@ -400,6 +615,35 @@ 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: @@ -515,10 +759,12 @@ class PurchasingBatchView(BatchMasterView): def get_batch_kwargs(self, batch, **kwargs): kwargs = super().get_batch_kwargs(batch, **kwargs) - model = self.model + model = self.app.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: @@ -536,6 +782,11 @@ 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: @@ -919,6 +1170,25 @@ 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 2e24eebb..c7cc7bfc 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -28,14 +28,10 @@ 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 @@ -51,6 +47,8 @@ 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", @@ -59,9 +57,14 @@ class OrderingBatchView(PurchasingBatchView): form_fields = [ 'id', 'store', - 'buyer', 'vendor', + 'description', + 'workflow', + 'order_file', + 'order_parser_key', + 'buyer', 'department', + 'params', 'purchase', 'vendor_email', 'vendor_fax', @@ -132,15 +135,26 @@ class OrderingBatchView(PurchasingBatchView): return self.enum.PURCHASE_BATCH_MODE_ORDERING def configure_form(self, f): - super(OrderingBatchView, self).configure_form(f) + super().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(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs) + kwargs = super().get_batch_kwargs(batch, **kwargs) kwargs['ship_method'] = batch.ship_method kwargs['notes_to_vendor'] = batch.notes_to_vendor return kwargs @@ -155,7 +169,7 @@ class OrderingBatchView(PurchasingBatchView): * ``cases_ordered`` * ``units_ordered`` """ - super(OrderingBatchView, self).configure_row_form(f) + super().configure_row_form(f) # when editing, only certain fields should allow changes if self.editing: @@ -308,7 +322,7 @@ class OrderingBatchView(PurchasingBatchView): title = self.get_instance_title(batch) order_date = batch.date_ordered if not order_date: - order_date = localtime(self.rattail_config).date() + order_date = self.app.today() return self.render_to_response('worksheet', { 'batch': batch, @@ -369,6 +383,7 @@ 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: @@ -478,13 +493,75 @@ 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(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs) + return super().get_execute_success_url(batch, result, **kwargs) + + def configure_get_simple_settings(self): + return [ + + # workflows + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_from_scratch', + 'type': bool, + 'default': True}, + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_from_file', + 'type': bool, + 'default': True}, + + # vendors + {'section': 'rattail.batch', + 'option': 'purchase.allow_ordering_any_vendor', + 'type': bool, + 'default': True, + }, + ] + + def configure_get_context(self): + context = super().configure_get_context() + vendor_handler = self.app.get_vendor_handler() + + Parsers = vendor_handler.get_all_order_parsers() + Supported = vendor_handler.get_supported_order_parsers() + context['order_parsers'] = Parsers + context['order_parsers_data'] = dict([(Parser.key, Parser in Supported) + for Parser in Parsers]) + + return context + + def configure_gather_settings(self, data): + settings = super().configure_gather_settings(data) + vendor_handler = self.app.get_vendor_handler() + + supported = [] + for Parser in vendor_handler.get_all_order_parsers(): + name = f'order_parser_{Parser.key}' + if data.get(name) == 'true': + supported.append(Parser.key) + settings.append({'name': 'rattail.vendors.supported_order_parsers', + 'value': ', '.join(supported)}) + + return settings + + def configure_remove_settings(self): + super().configure_remove_settings() + + names = [ + 'rattail.vendors.supported_order_parsers', + ] + + # nb. using thread-local session here; we do not use + # self.Session b/c it may not point to Rattail + session = Session() + for name in names: + self.app.delete_setting(session, name) @classmethod 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 de19a2b9..01858c98 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -108,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView): 'store', 'vendor', 'description', - 'receiving_workflow', + 'workflow', 'truck_dump', 'truck_dump_children_first', 'truck_dump_children', @@ -235,135 +235,18 @@ class ReceivingBatchView(PurchasingBatchView): if not self.handler.allow_truck_dump_receiving(): g.remove('truck_dump') - 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 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 row_deletable(self, row): @@ -404,13 +287,7 @@ class ReceivingBatchView(PurchasingBatchView): # cancel should take us back to choosing a workflow f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) - # 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') - + # TODO: remove this # batch_type if self.creating: f.set_widget('batch_type', dfwidget.HiddenWidget()) @@ -525,7 +402,7 @@ class ReceivingBatchView(PurchasingBatchView): # multiple invoice files (if applicable) if (not self.creating - and batch.get_param('receiving_workflow') == 'from_multi_invoice'): + and batch.get_param('workflow') == 'from_multi_invoice'): if 'invoice_files' not in f: f.insert_before('invoice_file', 'invoice_files') @@ -624,12 +501,6 @@ 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) @@ -654,42 +525,40 @@ 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'] - # TODO: ugh should just have workflow and no batch_type - kwargs['receiving_workflow'] = batch_type - if batch_type == 'from_scratch': + workflow = kwargs['workflow'] + if workflow == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) - elif batch_type == 'from_invoice': + elif workflow == 'from_invoice': pass - elif batch_type == 'from_multi_invoice': + elif workflow == 'from_multi_invoice': pass - elif batch_type == 'from_po': + elif workflow == 'from_po': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif batch_type == 'from_po_with_invoice': + elif workflow == 'from_po_with_invoice': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif batch_type == 'truck_dump_children_first': + elif workflow == 'truck_dump_children_first': kwargs['truck_dump'] = True kwargs['truck_dump_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 batch_type == 'truck_dump_children_last': + elif workflow == '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 batch_type.startswith('truck_dump_child'): + elif workflow.startswith('truck_dump_child'): truck_dump = self.get_instance() kwargs['store'] = truck_dump.store kwargs['vendor'] = truck_dump.vendor @@ -1986,6 +1855,12 @@ 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}, @@ -2036,6 +1911,7 @@ class ReceivingBatchView(PurchasingBatchView): @classmethod def defaults(cls, config): cls._receiving_defaults(config) + cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) @@ -2043,17 +1919,11 @@ 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), @@ -2106,33 +1976,6 @@ 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(), From 535317e4f769b2f39121060f70ed7a1c4a013aed Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 22 Oct 2024 15:04:40 -0500 Subject: [PATCH 184/211] fix: avoid deprecated method to suggest username --- tailbone/views/people.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index b6a4c0b9..d288b551 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -1382,8 +1382,8 @@ class PersonView(MasterView): } if not context['users']: - context['suggested_username'] = auth.generate_unique_username(self.Session(), - person=person) + context['suggested_username'] = auth.make_unique_username(self.Session(), + person=person) return context From 28f90ad6b5777dfe1c91db2d90c5ccccc678ad5e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 22 Oct 2024 17:09:29 -0500 Subject: [PATCH 185/211] =?UTF-8?q?bump:=20version=200.21.11=20=E2=86=92?= =?UTF-8?q?=200.22.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c31ae92..8ed82c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index 5b63a71f..b928ec9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.21.11" +version = "0.22.0" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 9a6f8970aeb6117d9240b4bd4f024bca4ee136cf Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 23 Oct 2024 09:46:14 -0500 Subject: [PATCH 186/211] fix: avoid deprecated grid method --- tailbone/views/master.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index baf63caa..2e7ac147 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -412,7 +412,7 @@ class MasterView(View): session = self.Session() kwargs.setdefault('paginated', False) grid = self.make_grid(session=session, **kwargs) - return grid.make_visible_data() + return grid.get_visible_data() def get_grid_columns(self): """ @@ -1710,7 +1710,7 @@ class MasterView(View): kwargs.setdefault('paginated', False) kwargs.setdefault('sortable', sort) grid = self.make_row_grid(session=session, **kwargs) - return grid.make_visible_data() + return grid.get_visible_data() @classmethod def get_row_url_prefix(cls): From 54220601edfde3435420d5e04b8e4883ae4b4d53 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 1 Nov 2024 17:47:46 -0500 Subject: [PATCH 187/211] fix: fix submit button for running problem report esp. on Chrome(-based) browsers --- tailbone/templates/reports/problems/view.mako | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index 00ac1503..5cdf2be5 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -45,11 +45,10 @@ <b-button @click="runReportShowDialog = false"> Cancel </b-button> - ${h.form(master.get_action_url('execute', instance))} + ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})} ${h.csrf_token(request)} <b-button type="is-primary" native-type="submit" - @click="runReportSubmitting = true" :disabled="runReportSubmitting" icon-pack="fas" icon-left="arrow-circle-right"> From 29743e70b7cba3a1b53917c24d0d5a1aaf70972e Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sat, 2 Nov 2024 16:56:28 -0500 Subject: [PATCH 188/211] =?UTF-8?q?bump:=20version=200.22.0=20=E2=86=92=20?= =?UTF-8?q?0.22.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed82c5d..4dde0159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.1 (2024-11-02) + +### Fix + +- fix submit button for running problem report +- avoid deprecated grid method + ## v0.22.0 (2024-10-22) ### Feat diff --git a/pyproject.toml b/pyproject.toml index b928ec9b..a4a64038 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.0" +version = "0.22.1" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 3f27f626df9f5d2ccb6ae6d52bba0abaa09ecca9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Sun, 10 Nov 2024 19:16:45 -0600 Subject: [PATCH 189/211] fix: avoid deprecated import --- tailbone/api/master.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 2d17339e..551d6428 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -26,7 +26,6 @@ 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 @@ -185,7 +184,7 @@ class APIMasterView(APIView): if sortcol: spec = { 'field': sortcol.field_name, - 'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc', + 'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc', } if sortcol.model_name: spec['model'] = sortcol.model_name From 772b6610cbd99199cd4aae9bf4bbc3c5b748d829 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Nov 2024 18:26:36 -0600 Subject: [PATCH 190/211] fix: always define `app` attr for ViewSupplement --- tailbone/views/master.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2e7ac147..21a5e58f 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -903,7 +903,7 @@ class MasterView(View): def valid_employee_uuid(self, node, value): if value: - model = self.model + model = self.app.model employee = self.Session.get(model.Employee, value) if not employee: node.raise_invalid("Employee not found") @@ -939,7 +939,7 @@ class MasterView(View): def valid_vendor_uuid(self, node, value): if value: - model = self.model + model = self.app.model vendor = self.Session.get(model.Vendor, value) if not vendor: node.raise_invalid("Vendor not found") @@ -1382,7 +1382,7 @@ class MasterView(View): return classes def make_revisions_grid(self, obj, empty_data=False): - model = self.model + model = self.app.model route_prefix = self.get_route_prefix() row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, @@ -2153,7 +2153,7 @@ class MasterView(View): Thread target for executing an object. """ app = self.get_rattail_app() - model = self.model + model = self.app.model session = app.make_session() obj = self.get_instance_for_key(key, session) user = session.get(model.User, user_uuid) @@ -2594,7 +2594,7 @@ class MasterView(View): """ # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.model + model = self.app.model route_prefix = self.get_route_prefix() info = session.query(model.TailbonePageHelp)\ @@ -2617,7 +2617,7 @@ class MasterView(View): """ # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.model + model = self.app.model route_prefix = self.get_route_prefix() info = session.query(model.TailbonePageHelp)\ @@ -2639,7 +2639,7 @@ class MasterView(View): # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.model + model = self.app.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2673,7 +2673,7 @@ class MasterView(View): # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.model + model = self.app.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -5541,7 +5541,7 @@ class MasterView(View): input_file_templates=True, output_file_templates=True): app = self.get_rattail_app() - model = self.model + model = self.app.model names = [] if simple_settings is None: @@ -6100,7 +6100,7 @@ class MasterView(View): renderer='json') -class ViewSupplement(object): +class ViewSupplement: """ Base class for view "supplements" - which are sort of like plugins which can "supplement" certain aspects of the view. @@ -6127,6 +6127,7 @@ class ViewSupplement(object): 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 @@ -6160,7 +6161,7 @@ class ViewSupplement(object): This is accomplished by subjecting the current base query to a join, e.g. something like:: - model = self.model + model = self.app.model query = query.outerjoin(model.MyExtension) return query """ From 9e55717041f9955cb61a971a62340acb5473ab5f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Nov 2024 18:28:41 -0600 Subject: [PATCH 191/211] fix: show continuum operation type when viewing version history --- tailbone/diffs.py | 6 +++++- tailbone/templates/master/view.mako | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index 98253c57..8303d9e9 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -27,6 +27,8 @@ Tools for displaying data diffs import sqlalchemy as sa import sqlalchemy_continuum as continuum +from rattail.enum import CONTINUUM_OPERATION + from pyramid.renderers import render from webhelpers2.html import HTML @@ -273,6 +275,8 @@ class VersionDiff(Diff): return { 'key': id(self.version), 'model_title': self.title, + 'operation': CONTINUUM_OPERATION.get(self.version.operation_type, + self.version.operation_type), 'diff_class': self.nature, 'fields': self.fields, 'values': values, diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 0a1f9c62..118c028c 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -196,6 +196,7 @@ <p class="block has-text-weight-bold"> {{ version.model_title }} + ({{ version.operation }}) </p> <table class="diff monospace is-size-7" From 20b3f87dbef3346de939d5eabaa18224cc146cce Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Nov 2024 18:30:50 -0600 Subject: [PATCH 192/211] fix: add basic master view for Product Costs --- tailbone/menus.py | 10 +++++ tailbone/views/products.py | 77 +++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/tailbone/menus.py b/tailbone/menus.py index 3ddee095..09d6f3f0 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -394,6 +394,11 @@ class TailboneMenuHandler(WuttaMenuHandler): 'route': 'products', 'perm': 'products.list', }, + { + 'title': "Product Costs", + 'route': 'product_costs', + 'perm': 'product_costs.list', + }, { 'title': "Departments", 'route': 'departments', @@ -451,6 +456,11 @@ class TailboneMenuHandler(WuttaMenuHandler): 'route': 'vendors', 'perm': 'vendors.list', }, + { + 'title': "Product Costs", + 'route': 'product_costs', + 'perm': 'product_costs.list', + }, {'type': 'sep'}, { 'title': "Ordering", diff --git a/tailbone/views/products.py b/tailbone/views/products.py index c546a0f4..ae6c550c 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, CustomerOrderItem +from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError @@ -2668,6 +2668,78 @@ 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() @@ -2677,6 +2749,9 @@ 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) From ac439c949b1760e46975292a7c19b81664b0b5f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 12 Nov 2024 19:45:24 -0600 Subject: [PATCH 193/211] fix: use local/custom enum for continuum operations since we can't rely on that existing in rattail proper, due to it not always having sqlalchemy --- tailbone/diffs.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index 8303d9e9..2e582b15 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -27,8 +27,6 @@ Tools for displaying data diffs import sqlalchemy as sa import sqlalchemy_continuum as continuum -from rattail.enum import CONTINUUM_OPERATION - from pyramid.renderers import render from webhelpers2.html import HTML @@ -272,11 +270,21 @@ 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': CONTINUUM_OPERATION.get(self.version.operation_type, - self.version.operation_type), + 'operation': operation, 'diff_class': self.nature, 'fields': self.fields, 'values': values, From bcaf0d08bcab4fe040504986eee3735b814b50d9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 18 Nov 2024 14:08:10 -0600 Subject: [PATCH 194/211] =?UTF-8?q?bump:=20version=200.22.1=20=E2=86=92=20?= =?UTF-8?q?0.22.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dde0159..b7167b3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index a4a64038..ef7d3584 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.1" +version = "0.22.2" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 980031f5245f814b3313a4e0438cfae4218a72dc Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Mon, 18 Nov 2024 14:59:50 -0600 Subject: [PATCH 195/211] fix: avoid error for trainwreck query when not a customer when viewing a person's profile, who does not have a customer record, the trainwreck query can't really return anything since it normally should be matching on the customer ID --- tailbone/views/people.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tailbone/views/people.py b/tailbone/views/people.py index d288b551..405b1ca3 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -564,15 +564,19 @@ class PersonView(MasterView): Method which must return the base query for the profile's POS Transactions grid data. """ - app = self.get_rattail_app() - customer = app.get_customer(person) + customer = self.app.get_customer(person) - key_field = app.get_customer_key_field() - customer_key = getattr(customer, key_field) - if customer_key is not None: - customer_key = str(customer_key) + 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 - trainwreck = app.get_trainwreck_handler() + trainwreck = self.app.get_trainwreck_handler() model = trainwreck.get_model() query = TrainwreckSession.query(model.Transaction)\ .filter(model.Transaction.customer_id == customer_key) From 993f066f2cb5da9bfabcf59a81627e5ff20dd7df Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Nov 2024 15:45:37 -0600 Subject: [PATCH 196/211] =?UTF-8?q?bump:=20version=200.22.2=20=E2=86=92=20?= =?UTF-8?q?0.22.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7167b3c..5ec4ef5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.3 (2024-11-19) + +### Fix + +- avoid error for trainwreck query when not a customer + ## v0.22.2 (2024-11-18) ### Fix diff --git a/pyproject.toml b/pyproject.toml index ef7d3584..2dca88db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.2" +version = "0.22.3" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 7171c7fb06fa634a0688f525202a4b898868a8d7 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Tue, 19 Nov 2024 20:53:23 -0600 Subject: [PATCH 197/211] fix: use vmodel for confirm password widget input since previously this did not work at all for butterball (vue3 + oruga) - although it was never clear why per se.. Refs: #1 --- tailbone/templates/deform/checked_password.pt | 4 +- tailbone/views/auth.py | 40 ++++++++----------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt index f78c0b85..2121f01d 100644 --- a/tailbone/templates/deform/checked_password.pt +++ b/tailbone/templates/deform/checked_password.pt @@ -1,6 +1,7 @@ <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;"> @@ -8,7 +9,7 @@ ${field.start_mapping()} <b-input type="password" name="${name}" - value="${field.widget.redisplay and cstruct or ''}" + v-model="${vmodel}" tal:attributes="class string: form-control ${css_class or ''}; style style; attributes|field.widget.attributes|{};" @@ -18,7 +19,6 @@ </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/views/auth.py b/tailbone/views/auth.py index a54a19a9..1338c107 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -44,28 +44,6 @@ 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): @@ -181,7 +159,23 @@ class AuthenticationView(View): self.request.user)) return self.redirect(self.request.get_referrer()) - schema = ChangePassword().bind(user=self.request.user, request=self.request) + def check_user_password(node, value): + auth = self.app.get_auth_handler() + user = self.request.user + if not auth.check_user_password(user, value): + node.raise_invalid("The password is incorrect") + + schema = colander.Schema() + + schema.add(colander.SchemaNode(colander.String(), + name='current_password', + widget=dfwidget.PasswordWidget(), + validator=check_user_password)) + + schema.add(colander.SchemaNode(colander.String(), + name='new_password', + widget=dfwidget.CheckedPasswordWidget())) + form = forms.Form(schema=schema, request=self.request) if form.validate(): auth = self.app.get_auth_handler() From aace6033c5ba63f0ae5b6c7e458702483b2e6c5f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 20 Nov 2024 20:16:06 -0600 Subject: [PATCH 198/211] fix: avoid error in product search for duplicated key --- tailbone/views/products.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index ae6c550c..8461ae03 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -1857,7 +1857,8 @@ 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) + session, term, lookup_fields=lookup_fields, + first_if_multiple=True) if product: final_results.append(self.search_normalize_result(product)) From f1c8ffedda2b88bd9b68faf3ec2161ede67ee972 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Fri, 22 Nov 2024 12:57:04 -0600 Subject: [PATCH 199/211] =?UTF-8?q?bump:=20version=200.22.3=20=E2=86=92=20?= =?UTF-8?q?0.22.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec4ef5c..b3b51f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index 2dca88db..bde9bf89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.3" +version = "0.22.4" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From 2c269b640b1f72ac2cf9fea6a051d496096e0a8c Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Sun, 1 Dec 2024 18:12:30 -0600 Subject: [PATCH 200/211] fix: let caller request safe HTML literal for rendered grid table mostly just for convenience --- tailbone/grids/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 73de42c6..134642dd 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1223,6 +1223,7 @@ 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 @@ -1239,7 +1240,10 @@ class Grid(WuttaGrid): if context['paginated']: context.setdefault('per_page', 20) context['view_click_handler'] = self.get_view_click_handler() - return render(template, context) + result = render(template, context) + if literal: + result = HTML.literal(result) + return result def get_view_click_handler(self): """ """ From 23bdde245abae2721b02c06eec2e0e172c3e53c6 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Tue, 10 Dec 2024 12:34:34 -0600 Subject: [PATCH 201/211] fix: require newer wuttaweb --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bde9bf89..dc66e364 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.14.0", + "WuttaWeb>=0.16.2", "zope.sqlalchemy>=1.5", ] From 7e559a01b3cdcfc3704b7ffa72cc2ec3df4c73f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Tue, 10 Dec 2024 12:52:49 -0600 Subject: [PATCH 202/211] fix: require newer rattail lib --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dc66e364..8c0c2c15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.18.5", + "rattail[db,bouncer]>=0.21.1", "sa-filters", "simplejson", "transaction", From 358b3b75a534daa7c84decd64566aca5d1c29328 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Tue, 10 Dec 2024 13:05:32 -0600 Subject: [PATCH 203/211] fix: whoops this is latest rattail --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8c0c2c15..759510ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.21.1", + "rattail[db,bouncer]>=0.20.1", "sa-filters", "simplejson", "transaction", From 950db697a0306a87306facf07ca32ad1614341c9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Mon, 16 Dec 2024 12:46:45 -0600 Subject: [PATCH 204/211] =?UTF-8?q?bump:=20version=200.22.4=20=E2=86=92=20?= =?UTF-8?q?0.22.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b51f8d..cbacf2a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index 759510ba..9c164772 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.4" +version = "0.22.5" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From c7ee9de9eb3b86c40e99987c10843bd4bee142f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Sat, 28 Dec 2024 16:43:22 -0600 Subject: [PATCH 205/211] fix: register vue3 form component for products -> make batch --- tailbone/templates/products/batch.mako | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index 9f969468..db029e5a 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -55,19 +55,20 @@ </%def> <%def name="render_form_template()"> - <script type="text/x-template" id="${form.component}-template"> + <script type="text/x-template" id="${form.vue_tagname}-template"> ${self.render_form_innards()} </script> </%def> <%def name="modify_vue_vars()"> ${parent.modify_vue_vars()} + <% request.register_component(form.vue_tagname, form.vue_component) %> <script> ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) let ${form.vue_component} = { - template: '#${form.component}-template', + template: '#${form.vue_tagname}-template', methods: { ## TODO: deprecate / remove the latter option here From e0ebd43e7abaa3292dd252135bc2d880b6b312ca Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Sat, 1 Feb 2025 15:18:12 -0600 Subject: [PATCH 206/211] =?UTF-8?q?bump:=20version=200.22.5=20=E2=86=92=20?= =?UTF-8?q?0.22.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbacf2a5..0b1726a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.6 (2025-02-01) + +### Fix + +- register vue3 form component for products -> make batch + ## v0.22.5 (2024-12-16) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 9c164772..9e83df80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.5" +version = "0.22.6" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -59,7 +59,7 @@ dependencies = [ "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.16.2", + "WuttaWeb>=0.21.0", "zope.sqlalchemy>=1.5", ] From 4221fa50dd95771c84c20473381edcaff006043d Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Fri, 14 Feb 2025 11:37:21 -0600 Subject: [PATCH 207/211] fix: fix warning msg for deprecated Grid param --- tailbone/grids/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 134642dd..56b97b86 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -235,7 +235,7 @@ class Grid(WuttaGrid): if 'pageable' in kwargs: warnings.warn("pageable param is deprecated for Grid(); " - "please use vue_tagname param instead", + "please use paginated param instead", DeprecationWarning, stacklevel=2) kwargs.setdefault('paginated', kwargs.pop('pageable')) From 7348eec671542fa1317ad68a0816948ee96c76ac Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Tue, 18 Feb 2025 11:16:23 -0600 Subject: [PATCH 208/211] fix: stop using old config for logo image url on login page --- tailbone/views/auth.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 1338c107..eceab803 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -94,10 +94,6 @@ 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() @@ -110,7 +106,6 @@ 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), } From a6508154cb93a376a7ec93efa930534c674364f8 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Tue, 18 Feb 2025 12:13:28 -0600 Subject: [PATCH 209/211] docs: update intersphinx doc links per server migration --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 52e384f5..ade4c92a 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://rattailproject.org/docs/rattail/', None), + 'rattail': ('https://docs.wuttaproject.org/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), - 'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None), - 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), + 'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None), + 'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None), } # allow todo entries to show up From e2582ffec5f84f97df9cc7d2fdcdf5201b2d135f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Wed, 19 Feb 2025 10:33:39 -0600 Subject: [PATCH 210/211] =?UTF-8?q?bump:=20version=200.22.6=20=E2=86=92=20?= =?UTF-8?q?0.22.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b1726a4..c974b3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index 9e83df80..a7214a8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.6" +version = "0.22.7" description = "Backoffice Web Application for Rattail" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] From e15045380171617b32f9dca6bcbda8b2c2472310 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@wuttaproject.org> Date: Wed, 5 Mar 2025 10:34:52 -0600 Subject: [PATCH 211/211] fix: add startup hack for tempmon DB model --- tailbone/app.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tailbone/app.py b/tailbone/app.py index b7262866..d2d0c5ef 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -62,6 +62,17 @@ 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)